5 Commits

Author SHA1 Message Date
Antigravity
afbb0dfc2d fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:13:07 +00:00
Antigravity
ebf6f16fde fix(ui): voix Second Brain à la connexion et libellés du rail
Remplace le titre « notepad » et le jargon (Intelligence OS, SM-2) par des mots lisibles, et aligne la langue des pages d’auth sur le reste du site.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 10:56:00 +00:00
Antigravity
e0fd5393ca fix: note en double au retour et barre Gras/Italique à l’ouverture
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m1s
CI / Deploy production (on server) (push) Successful in 23s
Ne plus réinjecter une note déjà dans la liste, et n’afficher le menu de formatage que sur une vraie sélection de texte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 08:30:06 +00:00
Antigravity
80ccc1f6de feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00
Antigravity
69c99e4f4f feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:51:43 +00:00
180 changed files with 18958 additions and 2112 deletions

45
.impeccable/config.json Normal file
View 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."
}
]
}
}

File diff suppressed because one or more lines are too long

88
PRODUCT.md Normal file
View 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 : lutilisateur é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 quun 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 dinterface suit le prototype quand il y a conflit dinterprétation.
- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern).
- Companion mobile Expo (`memento-mobile/`) existe ; **lautorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais nimpose pas un langage UI `adaptive` tant quil nest 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 lUI.
- 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 dabord** — 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 danglais hardcodé quand lUI est dans une autre langue.
5. **Preuve avant promesse** — sappuyer 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ù lUX 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.

View File

@@ -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. - **`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 dabus (exports répétés) ; hardening ultérieur. - **Rate limiting absent sur `GET /api/user/export`** — vecteur dabus (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.

View File

@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
- `customer.subscription.created` - `customer.subscription.created`
- `customer.subscription.updated` - `customer.subscription.updated`
- `customer.subscription.deleted` - `customer.subscription.deleted`
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin dessai)
- `invoice.payment_failed` - `invoice.payment_failed`
4. Copier la **signature secrète**`STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`) 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 | | É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.created` | Upsert (nouvelle souscription) | Selon Stripe |
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe | | `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` | | `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` | | `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
| `customer.subscription.trial_will_end` | Sync + email rappel fin dessai | `TRIALING` |
### 3.3 Mapping statuts Stripe → Prisma ### 3.3 Mapping statuts Stripe → Prisma

View File

@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
## Features ## 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 - 🔒 **API Key Authentication** with secure storage
- 🚀 **Performance Optimized** with connection pooling and caching - 🚀 **Performance Optimized** with connection pooling and caching
- 📊 **Observability** with Prometheus metrics export - 📊 **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 curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
``` ```
## Available Tools (22) ## Available Tools (29)
### Notes (13) ### 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_move_notes` | Move multiple notes at once |
| `batch_delete_notes` | Delete multiple notes at once | | `batch_delete_notes` | Delete multiple notes at once |
### Notebooks (6) ### Notebooks (7)
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `update_label` | Update a label | | `update_label` | Update a label |
| `delete_label` | Delete a label | | `delete_label` | Delete a label |
### Reminders (1) ### Reminders (3)
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `get_due_reminders` | Get due reminders | | `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) ### Utilities (2)

View File

@@ -197,8 +197,9 @@ export function validateConfig() {
} }
} }
// Port validation // Port validation (check raw value before clamping)
const portValidation = validatePort(config.port); const rawPort = Number.parseInt(env('PORT', '3001'), 10);
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
if (!portValidation.valid) { if (!portValidation.valid) {
errors.push({ key: 'PORT', message: portValidation.error, critical: true }); errors.push({ key: 'PORT', message: portValidation.error, critical: true });
} }

View File

@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
sessions: '/sessions', sessions: '/sessions',
}, },
auth: { enabled: config.requireAuth }, auth: { enabled: config.requireAuth },
tools: 22, tools: 29,
uptime: process.uptime(), uptime: process.uptime(),
}); });
}); });
@@ -445,25 +445,23 @@ app.all(
} }
// Validate tool input if present // Validate tool input if present
if (req.body?.method) { if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
const toolName = req.body.method; const toolName = req.body.params.name;
if (req.body?.params) { const validation = validateAndSanitize(toolName, req.body.params.arguments);
const validation = validateAndSanitize(toolName, req.body.params); if (!validation.success) {
if (!validation.success) { log('warn', `Validation failed for ${toolName}:`, validation.errors);
log('warn', `Validation failed for ${toolName}:`, validation.errors); return res
return res .status(400)
.status(400) .json(
.json( mcpError(McpErrors.INVALID_PARAMS.code, {
mcpError(McpErrors.INVALID_PARAMS.code, { detail: 'Input validation failed',
detail: 'Input validation failed', field: validation.errors[0]?.field,
field: validation.errors[0]?.field, context: { toolName, errors: validation.errors },
context: { toolName, errors: validation.errors }, })
}) );
);
}
// Update request with sanitized data
req.body.params = validation.data;
} }
// Update request with sanitized data
req.body.params.arguments = validation.data;
} }
const ctx = { userId: req.userSession?.userId || null }; const ctx = { userId: req.userSession?.userId || null };
@@ -539,7 +537,7 @@ async function main() {
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'} Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${config.requestTimeout}ms Timeout: ${config.requestTimeout}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'} Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22 Tools: 29
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''} Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
`); `);
}); });

View File

@@ -176,7 +176,7 @@ async function main() {
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'} Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
User: ${config.userId || 'all'} User: ${config.userId || 'all'}
Log Level: ${config.logLevel} Log Level: ${config.logLevel}
Tools: 22 Tools: 29
`); `);
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "memento-mcp-server", "name": "memento-mcp-server",
"version": "3.2.0", "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", "type": "module",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -5,7 +5,7 @@
* Run with: npm test * 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 { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
it('should allow safe HTML', () => { it('should allow safe HTML', () => {
const xss = checkXSS({ content: 'Hello <em>world</em>' }); const xss = checkXSS({ content: 'Hello <em>world</em>' });
// This will be true because we check for any HTML tags // Safe formatting tags are not flagged as XSS
// In production, you might want more sophisticated checking expect(xss).toBe(false);
expect(xss).toBe(true);
}); });
it('should sanitize input', () => { it('should sanitize input', () => {
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
'update_label', 'update_label',
'delete_label', 'delete_label',
'get_due_reminders', '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', 'export_notes',
'import_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'); const { toolSchemas } = await import('../validation.js');
for (const toolName of toolNames) { for (const toolName of toolNames) {

View File

@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500; const MAX_NOTES_LIMIT = 500;
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray'; 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) { export function parseNote(dbNote) {
if (!dbNote) return null; if (!dbNote) return null;
@@ -463,6 +463,100 @@ const toolDefinitions = [
description: 'Get notes with due reminders. Designed for cron/automation.', description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} }, 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 ────────────────────────────────────────────────────────── // ─── Tool Handlers ──────────────────────────────────────────────────────────
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
title: args.title || null, title: args.title || null,
content: args.content, content: args.content,
color: args.color || 'default', color: args.color || 'default',
type: args.type || 'text', type: args.type || 'richtext',
checkItems: args.checkItems ?? null, checkItems: args.checkItems ?? null,
labels: args.labels ?? null, labels: args.labels ?? null,
isPinned: args.isPinned || false, isPinned: args.isPinned || false,
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders }); 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 ═══ // ═══ TRASH ═══
case 'trash_note': { case 'trash_note': {
const note = await prisma.note.update({ const note = await prisma.note.update({

View File

@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
notebookId: idSchema.optional().nullable(), notebookId: idSchema.optional().nullable(),
fullDetails: boolSchema(false), fullDetails: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100), 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({ export const searchNotesSchema = z.object({
query: z.string().min(1).max(500), query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(50),
notebookId: idSchema.optional().nullable(), notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false), includeArchived: boolSchema(false),
}); });
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
*/ */
export const togglePinSchema = z.object({ export const togglePinSchema = z.object({
id: idSchema, id: idSchema,
pinned: z.boolean().optional(),
}); });
/** /**
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
*/ */
export const toggleArchiveSchema = z.object({ export const toggleArchiveSchema = z.object({
id: idSchema, id: idSchema,
archived: z.boolean().optional(),
}); });
/** /**
* batch_move_notes input schema * batch_move_notes input schema
*/ */
export const batchMoveNotesSchema = z.object({ 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(), notebookId: idSchema.optional().nullable(),
}); });
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
* batch_delete_notes input schema * batch_delete_notes input schema
*/ */
export const batchDeleteNotesSchema = z.object({ 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({ export const createNotebookSchema = z.object({
name: z.string().min(1).max(200), 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(), icon: z.string().max(50).optional().nullable(),
parentId: idSchema.optional().nullable(), parentId: idSchema.optional().nullable(),
order: z.number().int().optional(),
}); });
/** /**
* get_notebooks input schema * get_notebooks input schema
*/ */
export const getNotebooksSchema = z.object({ export const getNotebooksSchema = z.object({});
includeHierarchy: boolSchema(false),
includeTrashed: boolSchema(false),
});
/** /**
* get_notebook input schema * get_notebook input schema
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
export const updateNotebookSchema = z.object({ export const updateNotebookSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1).max(200).optional(), 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(), icon: z.string().max(50).optional().nullable(),
order: z.number().int().optional(),
parentId: idSchema.optional().nullable(), parentId: idSchema.optional().nullable(),
}); });
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
*/ */
export const createLabelSchema = z.object({ export const createLabelSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
color: colorSchema.default('default'), color: z.string().max(50).optional().nullable(),
notebookId: idSchema,
}); });
/** /**
* get_labels input schema * get_labels input schema
*/ */
export const getLabelsSchema = z.object({ 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 * get_due_reminders input schema
*/ */
export const getDueRemindersSchema = z.object({ 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_notes input schema * export_notes input schema
*/ */
export const exportNotesSchema = z.object({ export const exportNotesSchema = z.object({});
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
format: z.enum(['json', 'markdown']).default('json'),
});
/** /**
* import_notes input schema * import_notes input schema
*/ */
export const importNotesSchema = z.object({ export const importNotesSchema = z.object({
notes: z.array( data: z.object({
z.object({ version: z.string().optional(),
title: z.string().optional(), data: z.object({
content: z.string(), notes: z.array(z.any()).optional(),
color: colorSchema.optional(), labels: z.array(z.any()).optional(),
labels: labelsSchema, notebooks: z.array(z.any()).optional(),
notebookId: idSchema.optional().nullable(), }).optional(),
}) }),
).min(1).max(100), });
/**
* 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(), 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, get_due_reminders: getDueRemindersSchema,
export_notes: exportNotesSchema, export_notes: exportNotesSchema,
import_notes: importNotesSchema, 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) { if (error instanceof z.ZodError) {
return { return {
success: false, success: false,
errors: error.errors.map((e) => ({ errors: error.issues.map((e) => ({
field: e.path.join('.'), field: e.path.join('.'),
message: e.message, message: e.message,
code: e.code, code: e.code,

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/test.js', 'test/**/*.test.js'],
},
});

View File

@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig } from '@/app/actions/admin-billing' import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n' 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 = { type BillingAdminData = {
billingConfig: Record<string, string> billingConfig: Record<string, string>
@@ -19,7 +44,6 @@ type BillingAdminData = {
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }> topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
} }
tiers: string[] tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{ creditAllocations?: Array<{
tier: string tier: string
monthlyCredits: number | null monthlyCredits: number | null
@@ -31,6 +55,35 @@ type BillingAdminData = {
credits: number credits: number
defaultDisplay: string 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 = [ const SUBSCRIPTION_PRICE_KEYS = [
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_L', 'STRIPE_PRICE_CREDITS_L',
] as const ] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string { function formatStableUtc(iso: string): string {
const d = new Date(iso) const d = new Date(iso)
if (Number.isNaN(d.getTime())) return 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` 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 }) { export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage() const { t } = useLanguage()
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true') const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false) 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) => { const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true) 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <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> <p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div> </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="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="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"> <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> <div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3> <h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => ( {SUBSCRIPTION_PRICE_KEYS.map((key) => {
<div key={key} className="space-y-2"> const meta = priceMap[key]
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label> return (
<Input <div key={key} className="space-y-2">
id={key} <Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
name={key} <Input
defaultValue={initialData.billingConfig[key] ?? ''} id={key}
placeholder="price_..." name={key}
/> defaultValue={initialData.billingConfig[key] ?? ''}
</div> 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> </div>
<div className="border-t border-border/50 pt-4"> <div className="border-t border-border/50 pt-4">
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</form> </form>
</div> </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="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="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"> <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 })} {t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt {initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { ? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt), date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}` })}`
: ` · ${t('admin.billing.notSynced')}`} : ` · ${t('admin.billing.notSynced')}`}

View 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>
)
}

View File

@@ -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'; export default async function AuthLayout({
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({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; 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 ( return (
<LanguageProvider> <LanguageProvider initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden"> <AuthShell>{children}</AuthShell>
<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> </LanguageProvider>
); );
} }

View 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>
)
}

View 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" />
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react' import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import { import {
@@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() { export default function InsightsPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { t, language: locale } = useLanguage() const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback( const formatSyncTime = useCallback(
@@ -102,7 +103,7 @@ export default function InsightsPage() {
const [isStale, setIsStale] = useState(false) const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null) const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard') 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 [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size') const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */ /** Un seul cluster déplié à la fois (null = tous repliés) */
@@ -129,6 +130,15 @@ export default function InsightsPage() {
loadInitialData() 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 ─────────────────────────────────────────────────────── // ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo( const selectedCluster = useMemo(
@@ -435,7 +445,7 @@ export default function InsightsPage() {
<button <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" 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'))} onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
aria-label="Toggle sidebar" aria-label={t('insightsView.toggleMenu')}
> >
<Menu size={22} /> <Menu size={22} />
</button> </button>
@@ -707,7 +717,7 @@ export default function InsightsPage() {
style={{ backgroundColor: cluster.color }} style={{ backgroundColor: cluster.color }}
aria-hidden 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} {name}
</h3> </h3>
<span className="text-[9px] text-concrete shrink-0 tabular-nums"> <span className="text-[9px] text-concrete shrink-0 tabular-nums">

View File

@@ -5,7 +5,7 @@ import { BillingPlans } from '@/components/settings/billing-plans';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const metadata = { export const metadata = {
title: 'Billing', title: 'Facturation — Memento',
}; };
function Fallback() { function Fallback() {

View File

@@ -2,6 +2,7 @@
import { Menu } from 'lucide-react' import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings' import { SettingsNav } from '@/components/settings'
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
export default function SettingsLayout({ export default function SettingsLayout({
@@ -11,9 +12,10 @@ export default function SettingsLayout({
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
return ( return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950"> <div className="flex flex-col h-full bg-background">
<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"> <SettingsDocumentTitle />
<div className="flex items-start gap-3"> <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 <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" 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'))} onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
@@ -22,10 +24,10 @@ export default function SettingsLayout({
<Menu size={22} /> <Menu size={22} />
</button> </button>
<div> <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')} {t('settings.title')}
</h1> </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')} {t('settings.description')}
</p> </p>
</div> </div>

View File

@@ -15,7 +15,7 @@ export function McpSettingsHeader() {
{ text: t('mcpSettings.helpBox.step2') }, { text: t('mcpSettings.helpBox.step2') },
{ text: t('mcpSettings.helpBox.step3') }, { text: t('mcpSettings.helpBox.step3') },
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } }, { 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') },
]} ]}
/> />
) )

View File

@@ -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 { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
import { ReadingProgress } from '@/components/publish/reading-progress' import { ReadingProgress } from '@/components/publish/reading-progress'
import { CopyLinkButton } from '@/components/publish/copy-link-button' 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 }> }) { export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params const { slug } = await params
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
const note = await getPublishedNote(slug) const note = await getPublishedNote(slug)
if (!note) notFound() 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 usesAiLayout = Boolean(note.publishedContent)
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '') const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
const bodyHtml = processNoteHtmlForPublish(rawHtml) const bodyHtml = processNoteHtmlForPublish(rawHtml)
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '') const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
const readingTime = estimateReadingTime(readingSource) const readingTime = estimateReadingTime(readingSource)
const isStale = Boolean(
note.publishedSourceHash
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
)
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale } const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }

View File

@@ -1,89 +1,160 @@
'use client' 'use client'
import { motion } from 'motion/react' import { Check } from 'lucide-react'
import { Shield } from 'lucide-react' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react' 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() { export default function PricingPage() {
const { t } = useLanguage() const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly') 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 = [ const plans = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' }, { key: 'basic', popular: false, hasTrial: 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: 'pro',
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') }, 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 ( return (
<main className="min-h-screen bg-paper"> <main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
<section className="py-32 px-8"> <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]">
<div className="max-w-7xl mx-auto"> <Link href="/" className="flex items-center gap-2.5 group">
<div className="text-center mb-12"> <div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span> <span className="font-serif text-xl font-bold leading-none">M</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2> </div>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p> <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"> <section className="px-5 sm:px-8 py-28">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}> <div className="max-w-6xl mx-auto">
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span> <div className="text-center mb-12">
{billingInterval === 'monthly' && ( <span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
<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 }} /> {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> </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> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"> {plans.map((plan) => (
{PLANS.map((plan) => ( <div
<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'}`}> 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 && ( {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')} {t('landing.pricing.popular')}
</div> </span>
)} )}
<div className="mb-8"> <h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4> {t(`landing.pricing.${plan.key}.name`)}
<div className="flex items-baseline gap-1 mb-4"> </h2>
<span className="text-4xl font-serif font-medium">{plan.price}</span> <div className="flex items-baseline gap-1 mb-2">
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>} <span className="text-3xl font-serif">{plan.price}</span>
</div> {plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
</div> </div>
<div className="flex-1 space-y-4 mb-10"> {plan.hasTrial && (
{[0, 1, 2, 3, 4, 5].map(j => { <p className="text-[11px] font-semibold text-[#D4A373] mb-3">
const feat = t(`landing.pricing.${plan.key}.feature${j}`) {t('landing.pricing.trialBadge', { days: trialDays })}
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null </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 ( return (
<div key={j} className="flex items-start gap-3"> <li key={j} className="flex gap-2 text-sm text-white/80">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}> <Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
<Shield size={10} fill="currentColor" /> {feat}
</div> </li>
<span className="text-xs font-light">{feat}</span>
</div>
) )
})} })}
</div> </ul>
<button <Link
onClick={() => router.push('/register')} href="/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'}`} 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`)} {plan.hasTrial
</button> ? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,13 +2,24 @@ import { headers } from 'next/headers'
import Link from 'next/link' import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language' import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export const metadata = { export async function generateMetadata({
title: 'Privacy Policy — Memento', searchParams,
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.', }: {
alternates: { canonical: 'https://memento-note.com/privacy' }, searchParams?: Promise<{ lang?: string | string[] }>
robots: { index: true, follow: true }, }) {
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' type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr'] const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = [] const RTL: Locale[] = []
@@ -353,13 +364,15 @@ export default async function PrivacyPage({
</header> </header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24"> <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">Privacy</p> <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> <h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p> <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> <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]"> <nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<p className="text-[11px] uppercase tracking-[0.2em] text-white/40 mb-3"> <p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{locale === 'fr' ? 'Sommaire' : 'Contents'} {locale === 'fr' ? 'Sommaire' : 'Contents'}
</p> </p>
<ul className="space-y-1.5 text-sm"> <ul className="space-y-1.5 text-sm">

View File

@@ -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() { export async function getBillingAdminData() {
await checkAdmin() await checkAdmin()
const { getSystemConfig } = await import('@/lib/config') const { getSystemConfig } = await import('@/lib/config')
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
} }
}) })
const [stripeHealth, subscriptionStats] = await Promise.all([
getStripeHealth(billingConfig),
getSubscriptionStats(),
])
return { return {
entitlements, entitlements,
billingConfig, billingConfig,
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
creditAllocations, creditAllocations,
creditCosts, creditCosts,
creditPacks, creditPacks,
stripeHealth,
subscriptionStats,
} }
} }

View File

@@ -6,6 +6,7 @@ import { auth } from '@/auth'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client' import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
import { addUtcMonths } from '@/lib/billing/period'
// Schema pour la création d'utilisateur // Schema pour la création d'utilisateur
const CreateUserSchema = z.object({ const CreateUserSchema = z.object({
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
const oldTier = existing?.tier ?? 'BASIC' const oldTier = existing?.tier ?? 'BASIC'
const now = new Date() const now = new Date()
const periodEnd = new Date(now) const periodEnd = addUtcMonths(now, 1)
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
await prisma.subscription.upsert({ await prisma.subscription.upsert({
where: { userId }, where: { userId },

View 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)
}

View File

@@ -2,20 +2,49 @@
import { signIn } from '@/auth'; import { signIn } from '@/auth';
import { AuthError } from 'next-auth'; import { AuthError } from 'next-auth';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
export async function authenticate( export async function authenticate(
prevState: string | undefined, prevState: string | undefined,
formData: FormData, 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 { try {
await signIn('credentials', { await signIn('credentials', {
email: formData.get('email'), email,
password: formData.get('password'), password,
redirectTo: '/home', redirectTo: '/home',
}); });
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message); 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) { switch (error.type) {
case 'CredentialsSignin': case 'CredentialsSignin':
return 'Invalid credentials.'; return 'Invalid credentials.';

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
import { z } from 'zod'; import { z } from 'zod';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config'; import { getSystemConfig } from '@/lib/config';
import { sendVerificationEmail } from '@/lib/auth/email-verification';
const RegisterSchema = z.object({ const RegisterSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
}); });
export async function register(prevState: string | undefined, formData: FormData) { export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig(); 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) { if (!allowRegister) {
return 'Registration is currently disabled by the administrator.'; 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 { email, password, name } = validatedFields.data;
const normalizedEmail = email.toLowerCase();
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
try { try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existingUser) { if (existingUser) {
return 'User already exists.'; return 'User already exists.';
} }
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const role = isAdmin ? 'ADMIN' : 'USER';
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
await prisma.user.create({ await prisma.user.create({
data: { data: {
email: email.toLowerCase(), email: normalizedEmail,
password: hashedPassword, password: hashedPassword,
name, name,
role, role,
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
emailVerified: isAdmin ? new Date() : null,
}, },
}); });
// Attempt to sign in immediately after registration if (!isAdmin) {
// We cannot import signIn here directly if it causes circular deps or issues, const mailResult = await sendVerificationEmail({
// but usually it works. If not, redirecting to login is fine. email: normalizedEmail,
// Let's stick to redirecting to login but with a clear success message? name,
// Or better: lowercase the email to fix the potential bug. });
if (!mailResult.success) {
console.error('[register] verification email failed:', mailResult.error);
}
}
} catch (error) { } catch (error) {
console.error('Registration Error:', error); console.error('Registration Error:', error);
return 'Database Error: Failed to create user.'; return 'Database Error: Failed to create user.';
} }
redirect('/login'); if (isAdmin) {
redirect('/login?verified=1');
}
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
} }

View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 })
}
}

View 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 })
}
}

View File

@@ -8,25 +8,12 @@ import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod' import { z } from 'zod'
import { hasUserAiConsent, aiConsentForbiddenResponse } 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({ const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"), 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// Check authentication and user setting // Check authentication and user setting
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
const body = await req.json() const body = await req.json()
const { content: rawContent } = requestSchema.parse(body) const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML) const content = stripHtmlToPlainText(rawContent)
const content = stripHtml(rawContent)
// Vérifier qu'il y a au moins 10 mots if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length return NextResponse.json({ suggestions: [] })
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
} }
const config = await getSystemConfig() const config = await getSystemConfig()

View File

@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices'; import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import {
shouldOfferSubscriptionTrial,
SUBSCRIPTION_TRIAL_DAYS,
} from '@/lib/billing/trial';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { z } from 'zod'; import { z } from 'zod';
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http'; const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`; 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. // Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') { if (preferredMode === 'embedded') {
try { try {
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any, ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier }, metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: { metadata: { userId, tier } }, subscription_data: subscriptionData,
customer_update: { address: 'auto' }, customer_update: { address: 'auto' },
allow_promotion_codes: true, allow_promotion_codes: true,
} as any); } as any);
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
clientSecret: embedded.client_secret, clientSecret: embedded.client_secret,
sessionId: embedded.id, sessionId: embedded.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
}); });
} }
} catch (embeddedErr) { } catch (embeddedErr) {
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`, cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier }, metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: { metadata: { userId, tier } }, subscription_data: subscriptionData,
customer_update: { address: 'auto' }, customer_update: { address: 'auto' },
allow_promotion_codes: true, 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({ 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) { } catch (error) {
console.error('[billing/create-checkout]', error); console.error('[billing/create-checkout]', error);
const msg = error instanceof Error ? error.message : 'Failed to create checkout session'; const msg = error instanceof Error ? error.message : 'Failed to create checkout session';

View File

@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements'; import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe'; 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'; export const dynamic = 'force-dynamic';
@@ -23,56 +26,53 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') { if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string' const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription ? checkoutSession.subscription
: (checkoutSession.subscription as any).id; : (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any; const sub = await stripe.subscriptions.retrieve(subId);
const priceId = sub.items.data[0].price.id; const syncUserId =
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO'; (checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
const currentPeriodStartTimestamp = userId;
sub.current_period_start ?? if (syncUserId === userId) {
sub.items?.data?.[0]?.current_period_start ?? await syncSubscriptionFromStripe(sub, userId);
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),
},
});
} }
} catch (err) { } catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err); console.error('[billing/status] Failed to sync Stripe session:', err);
} }
} }
try { 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 { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId); const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices(); const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled(); const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs'); 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({ return NextResponse.json({
tier, tier,
effectiveTier, effectiveTier,
status, status,
currentPeriodEnd: currentPeriodEnd ?? null, currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: subscription?.currentPeriodStart ?? null, currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId, hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices, prices,
creditPacks, creditPacks,
billingEnabled, billingEnabled,

View File

@@ -12,6 +12,8 @@ import {
isCreditPackId, isCreditPackId,
resolvePackFromPriceId, resolvePackFromPriceId,
} from '@/lib/billing/credit-packs'; } from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
break; 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: default:
break; break;
} }

View File

@@ -59,6 +59,7 @@ export async function GET() {
const [ const [
recentNotes, recentNotes,
inboxCount, inboxCount,
inboxPreview,
dueFlashcards, dueFlashcards,
upcomingReminders, upcomingReminders,
unviewedInsights, unviewedInsights,
@@ -76,13 +77,20 @@ export async function GET() {
notebookId: true, updatedAt: true, createdAt: true, notebookId: true, updatedAt: true, createdAt: true,
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 8, take: 12,
}), }),
prisma.note.count({ prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null }, 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({ prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } }, where: { deck: { userId }, nextReviewAt: { lte: now } },
}), }),
@@ -124,7 +132,7 @@ export async function GET() {
prisma.note.findMany({ prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false }, 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' }, orderBy: { updatedAt: 'desc' },
take: 5, take: 5,
}), }),
@@ -162,7 +170,10 @@ export async function GET() {
insightRows = [...insightRows, ...viewedRecent] 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 const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({ ? await prisma.notebook.findMany({
where: { id: { in: notebookIds } }, where: { id: { in: notebookIds } },
@@ -177,6 +188,13 @@ export async function GET() {
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null, notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})), })),
inboxCount, inboxCount,
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
dueFlashcards, dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({ upcomingReminders: upcomingReminders.map(r => ({
id: r.id, id: r.id,
@@ -221,8 +239,10 @@ export async function GET() {
pinnedNotes: pinnedNotes.map(n => ({ pinnedNotes: pinnedNotes.map(n => ({
id: n.id, id: n.id,
title: n.title, title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId, notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(), updatedAt: n.updatedAt.toISOString(),
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})), })),
writingActivity, writingActivity,
agentSuggestions: agentSuggestions.map(s => ({ agentSuggestions: agentSuggestions.map(s => ({

View File

@@ -22,7 +22,7 @@ export async function GET() {
const userId = session.user.id const userId = session.user.id
const locale = await detectUserLanguage() const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}` const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
try { try {
const cached = await redis.get(cacheKey) const cached = await redis.get(cacheKey)
@@ -39,7 +39,7 @@ export async function GET() {
isArchived: false, isArchived: false,
updatedAt: { gte: weekAgo }, updatedAt: { gte: weekAgo },
}, },
select: { title: true, content: true }, select: { id: true, title: true, content: true, notebookId: true },
take: 20, take: 20,
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}) })
@@ -64,10 +64,14 @@ export async function GET() {
} }
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale 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). 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: Notes:
${snippets.slice(0, 3000)}` ${snippets.slice(0, 3000)}`
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
} }
const parsed = JSON.parse(jsonMatch[0]) 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 {} try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload) return NextResponse.json(payload)
} catch (error) { } catch (error) {

View File

@@ -5,8 +5,12 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service' import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent' import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId } from '@/lib/publish/types' import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render' 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 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) { export async function POST(request: NextRequest) {
const session = await auth() const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json() const body = await request.json().catch(() => null)
const { noteId, action, mode, template, language, rewrite } = body as { 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 noteId?: string
action?: string action?: string
mode?: 'simple' | 'ai' mode?: 'simple' | 'ai' | 'interactive-page'
template?: string template?: string
language?: string language?: string
rewrite?: boolean rewrite?: boolean
pageSpec?: unknown
} }
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 }) 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 (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') { 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' const publishMode = mode === 'ai' ? 'ai' : 'simple'
if (publishMode === 'ai') { if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) { if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse() return aiConsentForbiddenResponse()
} }
if (!template || !isPublishTemplateId(template)) { if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 }) return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
} }

View 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' } },
)
}

View File

@@ -1,18 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements'; import { getEffectiveTier } from '@/lib/entitlements';
import { isByokProviderAllowed } from '@/lib/byok'; import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list'; import { decryptApiKey } from '@/lib/crypto';
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router'; 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>] * 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. * Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
* - Live providers (openai, deepseek…): requires key to fetch live from provider. * Pas de liste de secours figée.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await auth(); const session = await auth();
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider; const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key') ?? ''; let apiKey = searchParams.get('key') ?? '';
const baseUrl = searchParams.get('baseUrl') ?? undefined; let baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider) { if (!provider) {
return NextResponse.json({ error: 'Missing provider' }, { status: 400 }); 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 }); return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
} }
// Static suggestion providers: return immediately without a key if (!apiKey || apiKey.length < 4) {
if (STATIC_PROVIDERS.has(provider)) { const saved = await getActiveByokKey(session.user.id, provider, tier);
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider; if (saved) {
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? []; try {
return NextResponse.json({ success: true, models, fromApi: false }); apiKey = await decryptApiKey(saved.encryptedKey);
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
} catch {
apiKey = '';
}
}
} }
// Live providers need a key
if (!apiKey || apiKey.length < 4) { 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); const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);

View File

@@ -42,8 +42,8 @@ const jetbrainsMono = JetBrains_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Memento - Your Digital Notepad", title: "Memento Your Second Brain",
description: "A beautiful note-taking app built with Next.js 16", description: "A Second Brain that connects while you write. Search, brief, and act on your ideas every morning.",
manifest: "/api/manifest", manifest: "/api/manifest",
icons: { icons: {
icon: "/icons/icon-512.svg", icon: "/icons/icon-512.svg",

View File

@@ -34,6 +34,8 @@ export const authConfig = {
nextUrl.pathname === '/login' || nextUrl.pathname === '/login' ||
nextUrl.pathname === '/register' || nextUrl.pathname === '/register' ||
nextUrl.pathname === '/forgot-password' || nextUrl.pathname === '/forgot-password' ||
nextUrl.pathname === '/check-email' ||
nextUrl.pathname === '/verify-email' ||
nextUrl.pathname.startsWith('/reset-password'); nextUrl.pathname.startsWith('/reset-password');
if (isAdminPage) { if (isAdminPage) {

View File

@@ -55,6 +55,16 @@ const typeConfig: Record<string, { icon: typeof Globe }> = {
'task-extractor': { icon: ListChecks }, '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> = { const frequencyKeys: Record<string, string> = {
manual: 'agents.frequencies.manual', manual: 'agents.frequencies.manual',
hourly: 'agents.frequencies.hourly', hourly: 'agents.frequencies.hourly',
@@ -209,9 +219,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
</div> </div>
<div className="space-y-1"> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -237,7 +247,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
{agent.description && ( {agent.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3"> <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{agent.description} {resolveStoredLabel(t, agent.description)}
</p> </p>
)} )}

View File

@@ -63,14 +63,26 @@ const templateConfig = [
type TemplateId = typeof templateConfig[number]['id'] type TemplateId = typeof templateConfig[number]['id']
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate' type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
const CATEGORIES: { id: CategoryId; label: string }[] = [ const CATEGORIES: { id: CategoryId; labelKey: string }[] = [
{ id: 'all', label: 'Tous' }, { id: 'all', labelKey: 'agents.templates.categoryAll' },
{ id: 'veille', label: '📡 Veille' }, { id: 'veille', labelKey: 'agents.templates.categoryWatch' },
{ id: 'digest', label: '📰 Digest' }, { id: 'digest', labelKey: 'agents.templates.categoryDigest' },
{ id: 'tools', label: '🔧 Outils' }, { id: 'tools', labelKey: 'agents.templates.categoryTools' },
{ id: 'generate', label: '✨ Génération' }, { 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> = { const typeIcons: Record<string, typeof Globe> = {
scraper: Globe, scraper: Globe,
researcher: Search, researcher: Search,
@@ -98,6 +110,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
const { t } = useLanguage() const { t } = useLanguage()
const [installingId, setInstallingId] = useState<string | null>(null) const [installingId, setInstallingId] = useState<string | null>(null)
const [activeCategory, setActiveCategory] = useState<CategoryId>('all') const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
const [showAll, setShowAll] = useState(false)
const handleInstall = async (tpl: typeof templateConfig[number]) => { const handleInstall = async (tpl: typeof templateConfig[number]) => {
setInstallingId(tpl.id) setInstallingId(tpl.id)
@@ -142,55 +155,62 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
} }
} }
const filtered = activeCategory === 'all' const filtered = (activeCategory === 'all'
? templateConfig ? templateConfig
: templateConfig.filter((tpl) => tpl.category === activeCategory) : 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 ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Category filter */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<button <button
key={cat.id} 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 ${ className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
activeCategory === cat.id activeCategory === cat.id
? 'bg-ink text-paper border-ink' ? 'bg-ink text-paper border-ink'
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30' : 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
}`} }`}
> >
{cat.label} {t(cat.labelKey)}
</button> </button>
))} ))}
</div> </div>
{/* Template grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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 Icon = templateIcons[tpl.id as TemplateId] ?? typeIcons[tpl.type] ?? Settings
const isInstalling = installingId === tpl.id const isInstalling = installingId === tpl.id
const nameKey = `agents.templates.${tpl.id}.name` const nameKey = templateNameKey(tpl.id)
const descKey = `agents.templates.${tpl.id}.description` const descKey = `agents.templates.${tpl.id}.description`
return ( return (
<div <div
key={tpl.id} 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" /> <Icon className="w-4 h-4" />
</div> </div>
<div className="flex items-start justify-between gap-2 mb-2"> <div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4> <h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
{tpl.frequency !== 'manual' && ( {tpl.frequency !== 'manual' && (
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0"> <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> </span>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p> <p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
<button <button
type="button"
onClick={() => handleInstall(tpl)} onClick={() => handleInstall(tpl)}
disabled={isInstalling} 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" 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> </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> </div>
) )
} }

View File

@@ -16,26 +16,16 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { providerDisplayName } from '@/lib/ai/provider-labels'
// ─── Provider display info ──────────────────────────────────────────────────── import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
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' },
}
function displayName(provider: string): string { 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']) const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
@@ -155,20 +145,20 @@ function EditKeyForm({
const showModelInput = manualModel || (!loadingModels && models.length === 0) const showModelInput = manualModel || (!loadingModels && models.length === 0)
return ( return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3"> <div className="mt-2 border border-brand-accent/20 rounded-xl bg-brand-accent/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400"> <p className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">
{t('byok.editLabel', { name: displayName(savedKey.provider) })} {t('byok.editLabel', { name: displayName(savedKey.provider) })}
</p> </p>
{/* Alias */} {/* Alias */}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1"> <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')} /> <Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div> </div>
{needsUrl && ( {needsUrl && (
<div className="space-y-1"> <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" /> <Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div> </div>
)} )}
@@ -176,7 +166,7 @@ function EditKeyForm({
{/* Model */} {/* Model */}
<div className="space-y-1"> <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 ? ( {showModelDropdown ? (
<Select value={model} onValueChange={setModel}> <Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger> <SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
@@ -189,7 +179,7 @@ function EditKeyForm({
{/* Key rotation (optional) */} {/* Key rotation (optional) */}
<div className="space-y-1"> <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> {t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label> </Label>
<Input <Input
@@ -205,7 +195,7 @@ function EditKeyForm({
{/* Test result */} {/* Test result */}
{testResult && ( {testResult && (
<div className={cn( <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 ? '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" />} {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" type="button"
disabled={!newKey || newKey.length < 8 || testing} disabled={!newKey || newKey.length < 8 || testing}
onClick={testModel} 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} />} {testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
{t('byok.test')} {t('byok.test')}
@@ -233,7 +223,7 @@ function EditKeyForm({
type="button" type="button"
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()} 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')} {saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button> </Button>
@@ -286,13 +276,6 @@ export function ByokSettingsPanel() {
setTestResult(null) setTestResult(null)
setModel('') setModel('')
setModels([]) 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) { async function refreshModels(p: string, key: string, _baseUrl?: string) {
@@ -403,10 +386,10 @@ export function ByokSettingsPanel() {
{/* Header */} {/* Header */}
<div className="flex items-start gap-5"> <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"> <div className="flex-1 space-y-1">
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3> <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>
</div> </div>
@@ -419,7 +402,7 @@ export function ByokSettingsPanel() {
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20' : 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}> )}>
{activeKey ? ( {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></> <><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)} )}
@@ -433,7 +416,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */} {/* Saved keys */}
{keys.length > 0 && ( {keys.length > 0 && (
<div className="space-y-2"> <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"> <ul className="space-y-1">
{keys.map((key) => ( {keys.map((key) => (
<li key={key.provider}> <li key={key.provider}>
@@ -447,9 +430,9 @@ export function ByokSettingsPanel() {
<div> <div>
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</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"> <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.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-[9px] text-concrete">{key.alias}</span>} {key.alias && <span className="text-[13px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}> <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')} {key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span> </span>
</div> </div>
@@ -462,7 +445,7 @@ export function ByokSettingsPanel() {
className={cn( className={cn(
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors', 'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
editingProvider === key.provider 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' : 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
)} )}
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)} onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
@@ -492,7 +475,7 @@ export function ByokSettingsPanel() {
{/* Inline edit form */} {/* Inline edit form */}
{editingProvider === key.provider && ( {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 <EditKeyForm
savedKey={key} savedKey={key}
onDone={() => setEditingProvider(null)} onDone={() => setEditingProvider(null)}
@@ -508,40 +491,40 @@ export function ByokSettingsPanel() {
{/* Add key form */} {/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5"> <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')} {keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</p> </p>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <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}> <Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger> <SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent> <SelectContent>
{allowed.map((p) => ( {allowed.map((p) => (
<SelectItem key={p} value={p}> <SelectItem key={p} value={p}>
<span className="font-medium">{displayName(p)}</span> <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> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <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} /> <Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div> </div>
</div> </div>
{NEEDS_BASE_URL.has(provider) && ( {NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5"> <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} /> <Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div> </div>
)} )}
<div className="space-y-1.5"> <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="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@@ -554,8 +537,8 @@ export function ByokSettingsPanel() {
</div> </div>
<button <button
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey} 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', 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-violet-400')} 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')} {verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button> </button>
@@ -564,7 +547,7 @@ export function ByokSettingsPanel() {
{provider && ( {provider && (
<div className="space-y-1.5"> <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 ? ( {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> <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 ? ( ) : showModelDropdown ? (
@@ -593,13 +576,13 @@ export function ByokSettingsPanel() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel} 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')} {testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button> </button>
<Button <Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()} 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"> <div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />} {saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}

View File

@@ -9,6 +9,8 @@ import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync' import { emitNoteChange } from '@/lib/note-change-sync'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
const NoteEditor = dynamic( const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })), () => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
@@ -23,6 +25,7 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
const { t } = useLanguage() const { t } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes) const [notes, setNotes] = useState<Note[]>(initialNotes)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null) 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 handleOpen = useCallback(async (note: Note, readOnly = false) => {
const fresh = await getNoteById(note.id) const fresh = await getNoteById(note.id)
@@ -50,17 +53,24 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
} }
}, [t]) }, [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)) setNotes((prev) => prev.filter((n) => n.id !== note.id))
try { try {
await deleteNote(note.id, { skipRevalidation: true }) await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId }) 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 { } catch {
setNotes((prev) => [note, ...prev]) setNotes((prev) => [note, ...prev])
toast.error(t('general.error')) toast.error(t('general.error'))
} }
}, [t]) }, [notePendingDelete, t])
if (editingNote) { if (editingNote) {
return ( return (
@@ -78,11 +88,20 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
} }
return ( return (
<NotesEditorialView <>
notes={notes} <NotesEditorialView
onOpen={handleOpen} notes={notes}
onArchiveNote={handleArchiveNote} onOpen={handleOpen}
onDeleteNote={handleDeleteNote} onArchiveNote={handleArchiveNote}
/> onDeleteNote={handleDeleteNote}
/>
<ConfirmDeleteNoteDialog
open={notePendingDelete != null}
onOpenChange={(open) => {
if (!open) setNotePendingDelete(null)
}}
onConfirm={confirmDeleteNote}
/>
</>
) )
} }

View 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>
);
}

View 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>
)
}

View File

@@ -86,18 +86,17 @@ export function DashboardActionStrip({
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button' const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion const motionProps = item.pulse && !prefersReducedMotion
? { ? {
key: `inbox-pulse-${inboxPulse}`,
animate: { scale: [1, 1.03, 1] }, animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 }, transition: { duration: 0.45 },
} }
: {} : {}
return ( return (
<Wrapper <Wrapper
key={item.key} key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
type="button" type="button"
onClick={item.onClick} onClick={item.onClick}
{...motionProps} {...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 item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm' ? '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' : 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
@@ -114,7 +113,7 @@ export function DashboardActionStrip({
}`}> }`}>
{item.value} {item.value}
</p> </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} {item.label}
</p> </p>
</div> </div>

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/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 { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -14,6 +14,11 @@ export interface AgentSuggestion {
suggestedFrequency: string suggestedFrequency: string
} }
export interface CreatedAgentNotice {
id: string | null
topic: string
}
export interface DashboardAgentCarouselProps { export interface DashboardAgentCarouselProps {
suggestions: AgentSuggestion[] suggestions: AgentSuggestion[]
loading?: boolean loading?: boolean
@@ -21,6 +26,9 @@ export interface DashboardAgentCarouselProps {
formatFrequency: (f: string) => string formatFrequency: (f: string) => string
onAccept: (id: string) => void onAccept: (id: string) => void
onDismiss: (id: string) => void onDismiss: (id: string) => void
createdAgent?: CreatedAgentNotice | null
onOpenCreated?: () => void
onClearCreated?: () => void
prefersReducedMotion?: boolean prefersReducedMotion?: boolean
} }
@@ -31,29 +39,46 @@ export function DashboardAgentCarousel({
formatFrequency, formatFrequency,
onAccept, onAccept,
onDismiss, onDismiss,
createdAgent,
onOpenCreated,
onClearCreated,
prefersReducedMotion, prefersReducedMotion,
}: DashboardAgentCarouselProps) { }: DashboardAgentCarouselProps) {
const { t } = useLanguage() const { t } = useLanguage()
const [idx, setIdx] = useState(0) 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"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))} onClick={goPrev}
disabled={idx === 0} disabled={safeIdx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25" className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')} aria-label={t('homeDashboard.intelPrev')}
> >
<ChevronLeft size={12} /> <ChevronLeft size={12} />
</button> </button>
<span className="text-[8px] font-mono text-concrete px-1"> <span className="text-[8px] font-mono text-concrete px-1">
{idx + 1}/{suggestions.length} {safeIdx + 1}/{suggestions.length}
</span> </span>
<button <button
type="button" type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))} onClick={goNext}
disabled={idx >= suggestions.length - 1} disabled={safeIdx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25" className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')} aria-label={t('homeDashboard.intelNext')}
> >
@@ -73,9 +98,18 @@ export function DashboardAgentCarousel({
icon={<Bot size={12} className="text-brand-accent" />} icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.suggestedResearch')} title={t('homeDashboard.suggestedResearch')}
actions={navActions} 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"> <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"> <p className="text-[11px] text-concrete leading-relaxed">
{t('homeDashboard.agentsEmpty')} {t('homeDashboard.agentsEmpty')}
@@ -83,7 +117,8 @@ export function DashboardAgentCarousel({
</div> </div>
) : ( ) : (
<AgentSlide <AgentSlide
current={suggestions[idx]} current={suggestions[safeIdx]}
direction={directionRef.current}
actingId={actingId} actingId={actingId}
formatFrequency={formatFrequency} formatFrequency={formatFrequency}
onAccept={onAccept} 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({ function AgentSlide({
current, current,
direction,
actingId, actingId,
formatFrequency, formatFrequency,
onAccept, onAccept,
@@ -106,6 +190,7 @@ function AgentSlide({
t, t,
}: { }: {
current: AgentSuggestion current: AgentSuggestion
direction: number
actingId: string | null actingId: string | null
formatFrequency: (f: string) => string formatFrequency: (f: string) => string
onAccept: (id: string) => void onAccept: (id: string) => void
@@ -115,7 +200,11 @@ function AgentSlide({
}) { }) {
const slide = prefersReducedMotion const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} } ? { 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 ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -148,7 +237,7 @@ function AgentSlide({
type="button" type="button"
disabled={actingId === current.id} disabled={actingId === current.id}
onClick={() => onAccept(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} {actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
{t('homeDashboard.createAgent')} {t('homeDashboard.createAgent')}

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import { motion, useReducedMotion } from 'motion/react'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react' import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap' import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
import { UsageMeter } from '@/components/usage-meter' import { UsageMeter } from '@/components/usage-meter'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { pathNoteTitle } from '@/lib/dashboard/path-title'
import type { DashboardWidgetId } from '@/lib/dashboard/layout' import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetShellProps { 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({ export function DashboardInboxWidget({
count, notes,
loading, loading,
onOpen, onOpen,
onSelect,
formatRelativeTime,
}: { }: {
count: number count: number
notes: Array<{
id: string
title: string | null
excerpt?: string
notebookId: string | null
updatedAt?: string
}>
loading: boolean loading: boolean
onOpen: () => void onOpen: () => void
onSelect: (id: string, notebookId: string | null) => void
formatRelativeTime?: (date: string) => string
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const reduced = !!useReducedMotion()
const untitled = t('homeDashboard.untitled')
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="inbox" widgetId="inbox"
@@ -58,19 +84,43 @@ export function DashboardInboxWidget({
compact compact
> >
{loading ? ( {loading ? (
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 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 <div className="space-y-1.5">
type="button" {notes.map((note, idx) => (
onClick={onOpen} <motion.button
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" key={note.id}
> type="button"
<div> onClick={() => onSelect(note.id, note.notebookId)}
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p> initial={reduced ? false : { opacity: 0, x: 8 }}
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p> animate={{ opacity: 1, x: 0 }}
</div> transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span> 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"
</button> >
<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> </DashboardWidgetShell>
) )
@@ -249,12 +299,22 @@ export function DashboardPinnedWidget({
notes, notes,
loading, loading,
onSelect, 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 loading: boolean
onSelect: (id: string, notebookId: string | null) => void onSelect: (id: string, notebookId: string | null) => void
formatRelativeTime?: (date: string) => string
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const untitled = t('homeDashboard.untitled')
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="pinned" widgetId="pinned"
@@ -269,17 +329,37 @@ export function DashboardPinnedWidget({
) : notes.length === 0 ? ( ) : notes.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p> <p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1.5">
{notes.map(n => ( {notes.map(n => {
<button const displayTitle = pathNoteTitle(n.title, n.excerpt) || untitled
key={n.id} const notebookName = n.notebook?.name || t('homeDashboard.pinnedNoNotebook')
type="button" const notebookColor = n.notebook?.color || '#C4A574'
onClick={() => onSelect(n.id, n.notebookId)} return (
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" <button
> key={n.id}
{n.title || t('homeDashboard.untitled')} type="button"
</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> </div>
)} )}
</DashboardWidgetShell> </DashboardWidgetShell>
@@ -309,8 +389,7 @@ export function DashboardActivityWidget({
</div> </div>
) : ( ) : (
<> <>
<RevisionHeatmap data={data} /> <RevisionHeatmap data={data} kind="edits" />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</> </>
)} )}
</DashboardWidgetShell> </DashboardWidgetShell>
@@ -397,15 +476,12 @@ export function DashboardFlashcardsProgressWidget({
style={{ width: `${retention}%` }} style={{ width: `${retention}%` }}
/> />
</div> </div>
{dueCount > 0 ? ( <p className="text-[9px] text-concrete leading-relaxed mb-2">
<p className="text-[10px] font-medium text-brand-accent group-hover:underline"> {t('homeDashboard.flashRetentionHint')}
{t('homeDashboard.flashDueCta', { count: dueCount })} </p>
</p> <p className="text-[10px] font-medium text-brand-accent group-hover:underline">
) : ( {dueCount > 0 ? t('homeDashboard.flashDueCta') : t('homeDashboard.flashOpenCta')}
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors"> </p>
{t('homeDashboard.flashOpenCta')}
</p>
)}
</button> </button>
)} )}
</DashboardWidgetShell> </DashboardWidgetShell>

View File

@@ -19,12 +19,22 @@ interface BridgeNote {
} }
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'] 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 { export interface DashboardMindOrbitProps {
clusters: Cluster[] clusters: Cluster[]
bridgeNotes: BridgeNote[] bridgeNotes: BridgeNote[]
loading?: boolean loading?: boolean
onOpenInsights: () => void onOpenInsights: () => void
onOpenCluster?: (clusterId: number) => void
onNoteSelect: (id: string) => void onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean prefersReducedMotion?: boolean
} }
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
bridgeNotes, bridgeNotes,
loading, loading,
onOpenInsights, onOpenInsights,
onOpenCluster,
onNoteSelect, onNoteSelect,
prefersReducedMotion, prefersReducedMotion,
}: DashboardMindOrbitProps) { }: DashboardMindOrbitProps) {
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
.slice(0, 5) .slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1 const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0] const topBridge = bridgeNotes[0]
const reduced = !!prefersReducedMotion
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
if (loading) { if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" /> 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) => { {topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length] const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55 const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale) const size = Math.round(52 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}` const label = cluster.name?.trim() || t('homeDashboard.theme')
const point = points[idx]
return ( return (
<motion.button <motion.button
key={cluster.clusterId} key={cluster.clusterId}
type="button" type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }} whileHover={reduced ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }} whileTap={reduced ? undefined : { scale: 0.97 }}
onClick={onOpenInsights} onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
className="relative flex flex-col items-center gap-1.5 group" className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
style={{ width: size + 16 }} 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 <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" 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} {cluster.noteIds.length}
</div> </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} {label}
</span> </span>
</motion.button> </motion.button>
) )
})} })}
</div> </motion.div>
{topBridge?.note && ( {topBridge?.note && (
<button <motion.button
type="button" type="button"
onClick={() => onNoteSelect(topBridge.noteId)} 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" 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" /> <Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1"> <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"> <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)}% {Math.round(topBridge.bridgeScore * 100)}%
</span> </span>
</button> </motion.button>
)} )}
</div> </div>
) )

View File

@@ -12,16 +12,16 @@ import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types' import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = { 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' }, connect: { Icon: Link2, accent: 'text-brand-accent' },
'add-link': { Icon: Plus, accent: 'text-emerald-600' }, 'add-link': { Icon: Plus, accent: 'text-brand-accent' },
bridge: { Icon: GitBranch, accent: 'text-violet-600' }, bridge: { Icon: GitBranch, accent: 'text-brand-accent' },
research: { Icon: Sparkles, accent: 'text-sky-600' }, research: { Icon: Sparkles, accent: 'text-brand-accent' },
explore: { Icon: Compass, accent: 'text-amber-600' }, explore: { Icon: Compass, accent: 'text-brand-accent' },
organize: { Icon: Inbox, accent: 'text-brand-accent' }, organize: { Icon: Inbox, accent: 'text-brand-accent' },
review: { Icon: GraduationCap, accent: 'text-brand-accent' }, review: { Icon: GraduationCap, accent: 'text-brand-accent' },
resurface: { Icon: Lightbulb, 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 { export interface DashboardNextPathsProps {
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
onAction, onAction,
prefersReducedMotion, prefersReducedMotion,
}: DashboardNextPathsProps) { }: DashboardNextPathsProps) {
const { t } = useLanguage() const { t, language } = useLanguage()
const rtl = language === 'ar' || language === 'fa'
if (loading) { if (loading) {
return ( return (
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
const hero = paths[0] const hero = paths[0]
const rest = paths.slice(1, 5) const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon const HeroIcon = TYPE_META[hero.type].Icon
const reduced = !!prefersReducedMotion
const ease = [0.16, 1, 0.3, 1] as const
return ( 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"> <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 <motion.button
type="button" type="button"
onClick={() => onAction(hero)} 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" 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"> <div className="flex items-start gap-3">
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
{rest.length > 0 && ( {rest.length > 0 && (
<div className="divide-y divide-border/10"> <div className="divide-y divide-border/10">
{rest.map(path => { {rest.map((path, idx) => {
const meta = TYPE_META[path.type] const meta = TYPE_META[path.type]
const Icon = meta.Icon const Icon = meta.Icon
return ( return (
<button <motion.button
key={path.id} key={path.id}
type="button" type="button"
onClick={() => onAction(path)} 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" 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}`} /> <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"> <span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)} {t(`homeDashboard.pathActions.${path.actionKey}`)}
</span> </span>
</button> </motion.button>
) )
})} })}
</div> </div>

View File

@@ -153,7 +153,7 @@ export function DashboardLinkSuggestions({
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="link-suggestions" 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')} title={t('homeDashboard.widgets.link-suggestions')}
> >
{loading ? ( {loading ? (
@@ -169,16 +169,16 @@ export function DashboardLinkSuggestions({
key={p.id} key={p.id}
type="button" type="button"
onClick={() => onAction(p.id)} 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"> <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 className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
{p.score ? ( {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} ) : null}
</div> </div>
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p> <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> </button>
))} ))}
</div> </div>
@@ -212,7 +212,7 @@ export function DashboardBridgesWidget({
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="bridges" widgetId="bridges"
icon={<Circle size={12} className="text-violet-600" />} icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.bridges')} title={t('homeDashboard.widgets.bridges')}
> >
{loading ? ( {loading ? (

View File

@@ -1,6 +1,5 @@
'use client' 'use client'
import { motion } from 'motion/react'
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react' import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -24,27 +23,33 @@ export interface DashboardResumeHeroProps {
prefersReducedMotion?: boolean 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({ export function DashboardResumeHero({
notes, notes,
loading, loading,
onSelect, onSelect,
onCaptureFocus, onCaptureFocus,
formatRelativeTime, formatRelativeTime,
prefersReducedMotion,
}: DashboardResumeHeroProps) { }: DashboardResumeHeroProps) {
const { t } = useLanguage() const { t } = useLanguage()
const hero = notes[0] const untitled = t('homeDashboard.untitled')
const rest = notes.slice(1, 6) const visible = notes.slice(0, 6)
if (loading) { if (loading) {
return ( 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-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="space-y-2">
<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-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> </div>
</div> </div>
) )
@@ -61,7 +66,7 @@ export function DashboardResumeHero({
/> />
</div> </div>
{!hero ? ( {visible.length === 0 ? (
<div className="px-5 pb-5"> <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"> <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} /> <Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
@@ -81,73 +86,33 @@ export function DashboardResumeHero({
</div> </div>
</div> </div>
) : ( ) : (
<> <div className="px-5 pb-4 space-y-1.5">
<motion.button {visible.map(note => (
type="button" <button
whileHover={prefersReducedMotion ? undefined : { y: -1 }} key={note.id}
onClick={() => onSelect(hero.id, hero.notebookId)} type="button"
className="w-full text-start px-5 pb-3 group cursor-pointer" 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"
<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
<span className="w-1.5 h-8 rounded-full shrink-0"
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0" style={{ backgroundColor: note.notebookColor }}
style={{ backgroundColor: hero.notebookColor }} aria-hidden
> />
{hero.notebookName} <div className="min-w-0 flex-1">
</span> <p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
<span className="text-[9px] font-mono text-concrete/70 shrink-0"> {resumeDisplayTitle(note, untitled)}
{formatRelativeTime(hero.updatedAt)} </p>
</span> <p className="text-[9px] text-concrete truncate mt-0.5">
</div> {note.notebookName}
<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')} {formatRelativeTime(note.updatedAt)}
</h3>
{hero.excerpt ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
</p> </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>
</div> <ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
</motion.button> </button>
))}
{rest.length > 0 && ( </div>
<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>
)}
</>
)} )}
</div> </div>
) )

View File

@@ -10,6 +10,12 @@ interface EmotionMeta {
color: string color: string
} }
export interface SentimentRelatedNote {
id: string
title: string | null
notebookId: string | null
}
export interface DashboardSentimentChipProps { export interface DashboardSentimentChipProps {
available: boolean available: boolean
loading?: boolean loading?: boolean
@@ -17,6 +23,8 @@ export interface DashboardSentimentChipProps {
summary?: string summary?: string
emotions?: Record<string, number> emotions?: Record<string, number>
emotionMeta: Record<string, EmotionMeta> emotionMeta: Record<string, EmotionMeta>
relatedNotes?: SentimentRelatedNote[]
onSelectNote?: (id: string, notebookId: string | null) => void
} }
export function DashboardSentimentChip({ export function DashboardSentimentChip({
@@ -26,6 +34,8 @@ export function DashboardSentimentChip({
summary, summary,
emotions, emotions,
emotionMeta, emotionMeta,
relatedNotes = [],
onSelectNote,
}: DashboardSentimentChipProps) { }: DashboardSentimentChipProps) {
const { t } = useLanguage() const { t } = useLanguage()
@@ -80,6 +90,24 @@ export function DashboardSentimentChip({
</p> </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 && ( {emotions && (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
{Object.entries(emotions) {Object.entries(emotions)

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react' import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useReducedMotion } from 'motion/react' 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 { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider' import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect' import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
@@ -30,6 +30,7 @@ import {
} from '@/components/dashboard-path-widgets' } from '@/components/dashboard-path-widgets'
import type { DashboardPath } from '@/lib/dashboard/path-types' import type { DashboardPath } from '@/lib/dashboard/path-types'
import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast' import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast'
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync' import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
import { import {
DashboardInboxWidget, DashboardInboxWidget,
@@ -50,8 +51,10 @@ import type { LucideIcon } from 'lucide-react'
interface BriefingPinnedNote { interface BriefingPinnedNote {
id: string id: string
title: string | null title: string | null
excerpt?: string
notebookId: string | null notebookId: string | null
updatedAt: string updatedAt: string
notebook?: { id: string; name: string; color: string | null; icon: string | null } | null
} }
interface ActivityDay { interface ActivityDay {
@@ -137,6 +140,7 @@ interface SentimentData {
emotions?: Record<string, number> emotions?: Record<string, number>
summary?: string summary?: string
topTopic?: string topTopic?: string
relatedNotes?: Array<{ id: string; title: string | null; notebookId: string | null }>
} }
interface MindMapData { interface MindMapData {
@@ -152,7 +156,7 @@ interface MindMapData {
} }
interface DashboardViewProps { interface DashboardViewProps {
onNoteSelect: (noteId: string, notebookId: string | null) => void onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
} }
interface GmailStatus { interface GmailStatus {
@@ -217,6 +221,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const [data, setData] = useState<{ const [data, setData] = useState<{
recentNotes: BriefingNote[] recentNotes: BriefingNote[]
inboxCount: number inboxCount: number
inboxPreview?: Array<{
id: string
title: string | null
excerpt?: string
notebookId: string | null
updatedAt?: string
}>
dueFlashcards: number dueFlashcards: number
upcomingReminders: BriefingReminder[] upcomingReminders: BriefingReminder[]
insights: BriefingInsight[] insights: BriefingInsight[]
@@ -244,6 +255,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const [capturing, setCapturing] = useState(false) const [capturing, setCapturing] = useState(false)
const [inboxPulse, setInboxPulse] = useState(0) const [inboxPulse, setInboxPulse] = useState(0)
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null) const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null)
const [createdAgent, setCreatedAgent] = useState<{ id: string | null; topic: string } | null>(null)
const [echoRefreshing, setEchoRefreshing] = useState(false) const [echoRefreshing, setEchoRefreshing] = useState(false)
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null) const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null) const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
@@ -266,7 +278,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => { const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => {
setPathsEnriching(true) setPathsEnriching(true)
try { try {
const focus = briefing.recentNotes[0] const focus = pickFocusNote(briefing.recentNotes)
const res = await fetch('/api/briefing/paths', { const res = await fetch('/api/briefing/paths', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -417,12 +429,22 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const gmail = data?.gmail const gmail = data?.gmail
const pinnedNotes = data?.pinnedNotes ?? [] const pinnedNotes = data?.pinnedNotes ?? []
const writingActivity = data?.writingActivity ?? [] 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 openLoopsList = openLoops
const briefingLoading = data === null const briefingLoading = data === null
const pathsLoading = data === null const pathsLoading = data === null
const pathsDetailLoading = pathsEnriching const pathsDetailLoading = pathsEnriching
const recentNotes = data?.recentNotes ?? []
const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes]) const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes])
const dateLocale = localeForLanguage(language) const dateLocale = localeForLanguage(language)
@@ -433,25 +455,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const themeCount = mindMap?.clusters.length ?? 0 const themeCount = mindMap?.clusters.length ?? 0
const resumeNotes = useMemo(() => recentNotes.map(n => ({ const resumeNotes = useMemo(() => recentNotes.flatMap(n => {
id: n.id, const displayTitle = pathNoteTitle(n.title, n.content)
title: n.title, if (!displayTitle) return []
excerpt: stripHtml(n.content).slice(0, 180), return [{
notebookName: n.notebook?.name || t('homeDashboard.inbox'), id: n.id,
notebookColor: n.notebook?.color || '#8B5CF6', title: displayTitle,
updatedAt: n.updatedAt, excerpt: stripHtml(n.content).slice(0, 180),
notebookId: n.notebookId, notebookName: n.notebook?.name || t('homeDashboard.inbox'),
})), [recentNotes, t]) notebookColor: n.notebook?.color || '#8B5CF6',
updatedAt: n.updatedAt,
const briefingSubtitle = useMemo(() => { notebookId: n.notebookId,
if (briefingLoading) return '' }]
const parts: string[] = [] }), [recentNotes, t])
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 handleCapture = useCallback(async () => { const handleCapture = useCallback(async () => {
const text = captureText.trim() const text = captureText.trim()
@@ -491,9 +507,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
} : prev) } : prev)
}, []) }, [])
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => { const handleOpenFromInsight = useCallback(async (
insight: BriefingInsight,
noteId: string,
peekNoteId?: string | null,
) => {
await markInsightViewed(insight.id) await markInsightViewed(insight.id)
onNoteSelect(noteId, null) const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
onNoteSelect(noteId, null, peek)
}, [markInsightViewed, onNoteSelect]) }, [markInsightViewed, onNoteSelect])
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => { const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
@@ -603,19 +624,20 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const handleAcceptSuggestion = useCallback(async (id: string) => { const handleAcceptSuggestion = useCallback(async (id: string) => {
setActingSuggestionId(id) setActingSuggestionId(id)
const topic = data?.agentSuggestions?.find(s => s.id === id)?.topic ?? ''
try { try {
const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' }) const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json.error) if (!res.ok) throw new Error(json.error)
setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev) setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev)
setCreatedAgent({ id: json.agentId ?? null, topic })
toast.success(t('homeDashboard.agentCreated')) toast.success(t('homeDashboard.agentCreated'))
if (json.agentId) router.push(`/agents?id=${json.agentId}`)
} catch { } catch {
toast.error(t('homeDashboard.agentFailed')) toast.error(t('homeDashboard.agentFailed'))
} finally { } finally {
setActingSuggestionId(null) setActingSuggestionId(null)
} }
}, [t, router]) }, [t, data?.agentSuggestions])
const handleDismissSuggestion = useCallback(async (id: string) => { const handleDismissSuggestion = useCallback(async (id: string) => {
setActingSuggestionId(id) setActingSuggestionId(id)
@@ -636,18 +658,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
break break
case 'compare': case 'compare':
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
break break
case 'addLink': case 'addLink':
if (path.noteId) { if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
onNoteSelect(path.noteId, path.notebookId ?? null)
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
}
break break
case 'openInsight': { case 'openInsight': {
const insight = insights.find(i => i.id === path.insightId) 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 break
} }
case 'createBridge': { case 'createBridge': {
@@ -750,12 +768,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
case 'capture': case 'capture':
return wrap( 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="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"> <div className="flex items-center gap-2 min-w-0">
<Inbox size={11} className="text-brand-accent shrink-0" /> <PenLine size={11} className="text-brand-accent shrink-0" />
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete"> <div className="min-w-0">
{t('homeDashboard.quickCapture')} <span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete block">
</span> {t('homeDashboard.quickCapture')}
</span>
<span className="text-[9px] text-concrete leading-tight">
{t('homeDashboard.captureGoesToFile')}
</span>
</div>
</div> </div>
<DashboardWidgetHelp widgetId="capture" /> <DashboardWidgetHelp widgetId="capture" />
</div> </div>
@@ -765,15 +788,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
onKeyDown={handleCaptureKeyDown} onKeyDown={handleCaptureKeyDown}
placeholder={t('homeDashboard.quickCapturePlaceholder')} placeholder={t('homeDashboard.quickCapturePlaceholder')}
rows={2} 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 <button
type="button" type="button"
onClick={handleCapture} onClick={handleCapture}
disabled={!captureText.trim() || capturing} 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> </button>
</div>, </div>,
) )
@@ -783,7 +810,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
paths={pathsList} paths={pathsList}
loading={pathsLoading} loading={pathsLoading}
enriching={pathsEnriching} enriching={pathsEnriching}
focusNoteTitle={recentNotes[0]?.title} focusNoteTitle={focusNote ? pathNoteTitle(focusNote.title, focusNote.content) : null}
onAction={handlePathAction} onAction={handlePathAction}
prefersReducedMotion={!!prefersReducedMotion} prefersReducedMotion={!!prefersReducedMotion}
/>, />,
@@ -882,6 +909,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
dismissingInsightId={dismissingInsightId} dismissingInsightId={dismissingInsightId}
actingBridgeSuggestionKey={actingBridgeSuggestionKey} actingBridgeSuggestionKey={actingBridgeSuggestionKey}
prefersReducedMotion={!!prefersReducedMotion} prefersReducedMotion={!!prefersReducedMotion}
excludeBridgeSuggestionKeys={pathBridgeKeys}
/>, />,
) )
case 'reminders': case 'reminders':
@@ -898,7 +926,18 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
<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> </div>
) : reminders.length === 0 ? ( ) : 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"> <div className="space-y-1.5">
{reminders.slice(0, 4).map(r => ( {reminders.slice(0, 4).map(r => (
@@ -916,33 +955,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
</span> </span>
</button> </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> </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>, </div>,
) )
case 'mind-map': case 'mind-map':
@@ -952,6 +973,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
bridgeNotes={mindMap?.bridgeNotes ?? []} bridgeNotes={mindMap?.bridgeNotes ?? []}
loading={mindMapLoading} loading={mindMapLoading}
onOpenInsights={() => router.push('/insights')} onOpenInsights={() => router.push('/insights')}
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
onNoteSelect={(nid) => onNoteSelect(nid, null)} onNoteSelect={(nid) => onNoteSelect(nid, null)}
prefersReducedMotion={!!prefersReducedMotion} prefersReducedMotion={!!prefersReducedMotion}
/>, />,
@@ -965,6 +987,9 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
formatFrequency={formatAgentFrequency} formatFrequency={formatAgentFrequency}
onAccept={handleAcceptSuggestion} onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion} onDismiss={handleDismissSuggestion}
createdAgent={createdAgent}
onOpenCreated={() => router.push(createdAgent?.id ? `/agents?id=${createdAgent.id}` : '/agents')}
onClearCreated={() => setCreatedAgent(null)}
prefersReducedMotion={!!prefersReducedMotion} prefersReducedMotion={!!prefersReducedMotion}
/>, />,
) )
@@ -977,14 +1002,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
summary={sentiment?.summary} summary={sentiment?.summary}
emotions={sentiment?.emotions} emotions={sentiment?.emotions}
emotionMeta={EMOTION_META} emotionMeta={EMOTION_META}
relatedNotes={sentiment?.relatedNotes}
onSelectNote={onNoteSelect}
/>, />,
) )
case 'inbox': case 'inbox':
return wrap( return wrap(
<DashboardInboxWidget <DashboardInboxWidget
count={inboxCount} count={inboxCount}
notes={data?.inboxPreview ?? []}
loading={briefingLoading} loading={briefingLoading}
onOpen={() => router.push('/home?forceList=1')} onOpen={() => router.push('/home?forceList=1')}
onSelect={onNoteSelect}
formatRelativeTime={relTime}
/>, />,
) )
case 'revision': case 'revision':
@@ -1035,6 +1065,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
notes={pinnedNotes} notes={pinnedNotes}
loading={briefingLoading} loading={briefingLoading}
onSelect={onNoteSelect} onSelect={onNoteSelect}
formatRelativeTime={relTime}
/>, />,
) )
case 'usage': case 'usage':
@@ -1050,11 +1081,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho, aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho,
handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion, handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion,
handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId, handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId,
actingBridgeSuggestionKey, reminders, gmail, dateLocale, router, mindMap, actingBridgeSuggestionKey, pathBridgeKeys, reminders, gmail, dateLocale, router, mindMap,
agentSuggestions, actingSuggestionId, formatAgentFrequency, handleAcceptSuggestion, agentSuggestions, actingSuggestionId, createdAgent, formatAgentFrequency, handleAcceptSuggestion,
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount, handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths, pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
handleOpenDailyNote, flashcardStats, handleOpenDailyNote, flashcardStats, focusNote,
]) ])
return ( return (
@@ -1070,7 +1101,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
<button <button
type="button" type="button"
onClick={() => { void reloadBriefingAndPaths() }} onClick={() => { void reloadBriefingAndPaths() }}
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-ink text-white dark:bg-white dark:text-black" 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')} {t('homeDashboard.briefingRetry')}
</button> </button>
@@ -1078,20 +1109,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
)} )}
{/* ── En-tête : orientation en 2 secondes ── */} {/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5"> <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 className="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">
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight"> {t('homeDashboard.title')}
{t('homeDashboard.title')} </h1>
</h1> <p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
<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' })}
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })} </p>
</p>
</div>
{!briefingLoading && briefingSubtitle && (
<p className="text-[11px] text-concrete leading-relaxed max-w-md sm:text-end">
{briefingSubtitle}
</p>
)}
</div> </div>
{/* Pulse : file d'attente cognitive en un coup d'œil */} {/* Pulse : file d'attente cognitive en un coup d'œil */}
@@ -1113,8 +1137,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
</div> </div>
</header> </header>
<div className="pb-24"> <div className="pb-8">
<DashboardWidgetGrid renderWidget={renderWidget} /> <DashboardWidgetGrid
renderWidget={renderWidget}
isWidgetEmpty={(id) => {
if (briefingLoading) return false
if (id === 'revision') return dueFlashcards === 0
return false
}}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -40,6 +40,7 @@ import { toast } from 'sonner'
interface DashboardWidgetGridProps { interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode renderWidget: (id: DashboardWidgetId) => React.ReactNode
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
} }
function SortableWidget({ function SortableWidget({
@@ -139,7 +140,7 @@ function ZoneColumn({
) )
} }
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) { export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
const { t } = useLanguage() const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout()) const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
@@ -228,15 +229,30 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
}, [persistLayout, t]) }, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full') const fullWidgets = visibleWidgetsInZone(layout, 'full')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const mainWidgets = visibleWidgetsInZone(layout, 'main') const mainWidgets = visibleWidgetsInZone(layout, 'main')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const sideWidgets = visibleWidgetsInZone(layout, 'side') const sideWidgets = visibleWidgetsInZone(layout, 'side')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const hidden = hiddenWidgetIds(layout) const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout) const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0 const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
return ( 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 ? ( {loaded ? (
hasVisibleWidgets ? ( hasVisibleWidgets ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
@@ -307,9 +323,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
)} )}
</div> </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"> <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 <button
type="button" type="button"
onClick={() => setCatalogOpen(v => !v)} onClick={() => setCatalogOpen(v => !v)}
@@ -337,18 +352,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
<Check size={12} /> <Check size={12} />
{t('homeDashboard.widgetDone')} {t('homeDashboard.widgetDone')}
</button> </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> </div>
)}
{editMode && catalogOpen && ( {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"> <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">

View File

@@ -10,6 +10,7 @@ interface DashboardWidgetTitleRowProps {
title: string title: string
actions?: ReactNode actions?: ReactNode
className?: string className?: string
wrapTitle?: boolean
} }
/** Titre widget : actions dabord, aide « ? » en dernier — ne masque jamais la navigation. */ /** Titre widget : actions dabord, aide « ? » en dernier — ne masque jamais la navigation. */
@@ -19,12 +20,15 @@ export function DashboardWidgetTitleRow({
title, title,
actions, actions,
className = 'mb-3', className = 'mb-3',
wrapTitle,
}: DashboardWidgetTitleRowProps) { }: DashboardWidgetTitleRowProps) {
return ( return (
<div className={`flex items-center justify-between gap-2 ${className}`}> <div className={`flex items-start justify-between gap-2 ${className}`}>
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-start gap-2 min-w-0 flex-1">
{icon} {icon ? <span className="shrink-0 mt-0.5">{icon}</span> : null}
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate"> <h2 className={`text-[13px] font-semibold uppercase tracking-wider text-ink dark:text-dark-ink ${
wrapTitle ? 'leading-tight whitespace-normal' : 'truncate'
}`}>
{title} {title}
</h2> </h2>
</div> </div>

View File

@@ -12,6 +12,8 @@ interface HeatmapDay {
interface RevisionHeatmapProps { interface RevisionHeatmapProps {
data: HeatmapDay[] data: HeatmapDay[]
className?: string 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 { function intensityClass(count: number, max: number): string {
@@ -28,19 +30,25 @@ function resolveDateLocale(langCode: string): string {
return langCode return langCode
} }
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) { export function RevisionHeatmap({ data, className, kind = 'reviews' }: RevisionHeatmapProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null) 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 [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
const dateLocale = resolveDateLocale(language ?? 'en') 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 { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count])) const map = new Map(data.map((d) => [d.date, d.count]))
const now = new Date() 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 todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
const cells: { date: string; count: number; label: string }[] = [] const cells: { date: string; count: number; label: string }[] = []
const monthLabels: { index: number; label: string }[] = [] const monthLabels: { index: number; label: string }[] = []
let lastMonth = -1 let lastMonth = -1
@@ -78,22 +86,22 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
return ( return (
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
{/* En-tête */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete"> <p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
{t('flashcards.heatmapTitle')} {t(`${prefix}Title`)}
</p> </p>
<span className="text-[10px] text-concrete/60"> <span className="text-[13px] text-concrete/70">
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')} {totalReviews > 0
? t(`${prefix}Total`, { count: totalReviews })
: t(kind === 'edits' ? 'homeDashboard.activityHeatmapLast90' : 'flashcards.heatmapLast90')}
</span> </span>
</div> </div>
{/* Labels de mois au-dessus de la grille */} <div className="relative h-6">
<div className="relative h-4">
{monthLabels.map((m) => ( {monthLabels.map((m) => (
<span <span
key={m.label + m.index} 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) }} style={{ left: pct(m.index) }}
> >
{m.label} {m.label}
@@ -101,14 +109,11 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
))} ))}
</div> </div>
{/* Grille pleine largeur */}
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]"> <div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => { {cells.map((cell) => {
const isHovered = hovered?.date === cell.date const isHovered = hovered?.date === cell.date
const isSelected = selected?.date === cell.date const isSelected = selected?.date === cell.date
const reviewText = cell.count > 0 const reviewText = dayLabel(cell.count)
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
: 'Aucune révision'
return ( return (
<button <button
@@ -134,47 +139,29 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
})} })}
</div> </div>
{/* Info au survol / clic */} <div className="min-h-7 flex items-center text-[13px] border-b border-border/20 pb-1">
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
{activeInfo ? ( {activeInfo ? (
<p className="flex items-center gap-1.5 animate-fadeIn"> <p className="flex items-center gap-1.5 animate-fadeIn">
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{activeInfo.count > 0 {dayLabel(activeInfo.count)}
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
: 'Aucune révision'}
</span> </span>
<span className="text-concrete">· {activeInfo.label}</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>
) : ( ) : (
<p className="text-[10px] text-concrete/40 italic"> <p className="text-[13px] text-concrete/50 italic">
Survolez ou cliquez sur un carré pour voir le détail {t(`${prefix}Hint`)}
</p> </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> </div>
{/* Légende */}
<div className="flex items-center gap-2 pt-0.5"> <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"> <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) => ( {['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 key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
))} ))}
</div> </div>
<span className="text-[9px] text-concrete/50">Plus</span> <span className="text-[13px] text-concrete/60">{t('flashcards.heatmapMore')}</span>
</div> </div>
</div> </div>
) )

View File

@@ -35,6 +35,8 @@ import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-
import { toast } from 'sonner' import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route' 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' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -160,6 +162,7 @@ export function HomeClient({
const aiMenuRef = useRef<HTMLDivElement>(null) const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false) const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false) const [showOrganizer, setShowOrganizer] = useState(false)
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
const handleExportCSV = useCallback(() => { const handleExportCSV = useCallback(() => {
if (!searchParams.get('notebook')) return if (!searchParams.get('notebook')) return
@@ -318,6 +321,8 @@ export function HomeClient({
} }
setNotes((prevNotes) => { setNotes((prevNotes) => {
if (prevNotes.some((n) => n.id === note.id)) return prevNotes
const notebookFilter = searchParams.get('notebook') const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || [] const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color') const colorFilter = searchParams.get('color')
@@ -533,21 +538,25 @@ export function HomeClient({
[patchNoteInList, t] [patchNoteInList, t]
) )
const handleDeleteNoteFromList = useCallback( const handleDeleteNoteFromList = useCallback((note: Note) => {
async (note: Note) => { setNotePendingDelete(note)
removeNoteFromList(note.id) }, [])
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
try { const confirmDeleteNoteFromList = useCallback(async () => {
await deleteNote(note.id, { skipRevalidation: true }) const note = notePendingDelete
toast.success(t('notes.deleted') || 'Note supprimée') if (!note) return
} catch { setNotePendingDelete(null)
setNotes((prev) => [note, ...prev]) removeNoteFromList(note.id)
emitNoteChange({ type: 'created', note }) emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.error(t('general.error')) try {
} await deleteNote(note.id, { skipRevalidation: true })
}, showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
[removeNoteFromList, t] } catch {
) setNotes((prev) => [note, ...prev])
emitNoteChange({ type: 'created', note })
toast.error(t('general.error'))
}
}, [notePendingDelete, removeNoteFromList, t])
const handleArchiveNoteFromList = useCallback( const handleArchiveNoteFromList = useCallback(
async (note: Note) => { async (note: Note) => {
@@ -630,12 +639,16 @@ export function HomeClient({
removeNoteFromList(detail.noteId) removeNoteFromList(detail.noteId)
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId)) setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
} else if (detail.type === 'created' && detail.note) { } 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) window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange) return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [patchNoteInList]) }, [patchNoteInList, filterNotesForCurrentView])
const handleGridReorder = useCallback( const handleGridReorder = useCallback(
async (orderedIds: string[]) => { async (orderedIds: string[]) => {
@@ -657,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.) // Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
useEffect(() => { useEffect(() => {
const openNoteId = searchParams.get('openNote') const openNoteId = searchParams.get('openNote')
if (!openNoteId) return if (!openNoteId) {
setEditingNote(null)
return
}
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
@@ -784,7 +800,12 @@ export function HomeClient({
// Apply sort order to notes // Apply sort order to notes
const sortedNotes = useMemo(() => { 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 === '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 === '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 || '')) if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
@@ -816,8 +837,15 @@ export function HomeClient({
const handleEditorClose = useCallback(() => { const handleEditorClose = useCallback(() => {
setEditingNote(null) 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()) const params = new URLSearchParams(searchParams.toString())
params.delete('openNote') params.delete('openNote')
params.delete('peekNote')
params.delete('from')
const qs = params.toString() const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false }) router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams]) }, [router, searchParams])
@@ -831,10 +859,17 @@ export function HomeClient({
// Show dashboard when no active filter/view params // Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams) 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() const params = new URLSearchParams()
params.set('openNote', noteId) 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.push(`/home?${params.toString()}`)
}, [router]) }, [router])
@@ -1463,6 +1498,14 @@ export function HomeClient({
/> />
)} )}
<ConfirmDeleteNoteDialog
open={notePendingDelete != null}
onOpenChange={(open) => {
if (!open) setNotePendingDelete(null)
}}
onConfirm={confirmDeleteNoteFromList}
/>
{showNotebookSlides && currentNotebook && ( {showNotebookSlides && currentNotebook && (
<NotebookSlidesDialog <NotebookSlidesDialog
notebookId={currentNotebook.id} notebookId={currentNotebook.id}

View File

@@ -10,6 +10,7 @@ import {
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n' import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
import { pathNoteTitle } from '@/lib/dashboard/path-title'
// ─── Types ───────────────────────────────────────────── // ─── Types ─────────────────────────────────────────────
@@ -104,14 +105,14 @@ function ConnectionDiagram({
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start" className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }} style={{ borderColor: `${color}30` }}
> >
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight"> <p className="text-sm font-semibold text-ink dark:text-dark-ink line-clamp-2 leading-tight" title={note1Title}>
{note1Title} {note1Title}
</p> </p>
</div> </div>
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1"> <div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} /> <div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
<span <span
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full" className="text-[13px] font-medium px-2 py-0.5 rounded-full"
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }} style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
> >
{Math.round(score * 100)}% {Math.round(score * 100)}%
@@ -122,7 +123,7 @@ function ConnectionDiagram({
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start" className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }} style={{ borderColor: `${color}30` }}
> >
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight"> <p className="text-sm font-semibold text-ink dark:text-dark-ink line-clamp-2 leading-tight" title={note2Title}>
{note2Title} {note2Title}
</p> </p>
</div> </div>
@@ -149,10 +150,11 @@ export interface IntelligenceHubProps {
onDismissInsight: (insight: IntelBriefingInsight) => void onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string, peekNoteId?: string | null) => void
dismissingInsightId: string | null dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean prefersReducedMotion: boolean
excludeBridgeSuggestionKeys?: string[]
} }
// ─── Component ───────────────────────────────────────── // ─── Component ─────────────────────────────────────────
@@ -178,6 +180,7 @@ export function IntelligenceHub({
dismissingInsightId, dismissingInsightId,
actingBridgeSuggestionKey, actingBridgeSuggestionKey,
prefersReducedMotion, prefersReducedMotion,
excludeBridgeSuggestionKeys,
}: IntelligenceHubProps) { }: IntelligenceHubProps) {
const router = useRouter() const router = useRouter()
const { t } = useLanguage() const { t } = useLanguage()
@@ -226,10 +229,13 @@ export function IntelligenceHub({
}) })
} }
const hiddenSuggestions = new Set(excludeBridgeSuggestionKeys ?? [])
for (const suggestion of bridgeSuggestions) { for (const suggestion of bridgeSuggestions) {
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
if (hiddenSuggestions.has(key)) continue
items.push({ items.push({
kind: 'suggestion', kind: 'suggestion',
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`, id: `suggestion-${key}`,
priority: 80, priority: 80,
suggestion, suggestion,
}) })
@@ -245,7 +251,7 @@ export function IntelligenceHub({
} }
return items.sort((a, b) => b.priority - a.priority).slice(0, 10) return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
}, [insights, bridgeNotes, bridgeSuggestions, agentActions]) }, [insights, bridgeNotes, bridgeSuggestions, agentActions, excludeBridgeSuggestionKeys])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
all: allItems.length, all: allItems.length,
@@ -302,40 +308,40 @@ export function IntelligenceHub({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Sparkles size={12} className="text-indigo-500" /> <Sparkles size={12} className="text-brand-accent" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400"> <span className="text-[13px] font-medium text-brand-accent">
{t('homeDashboard.semanticConnection')} {t('homeDashboard.semanticConnection')}
</span> </span>
{!insight.viewed && ( {!insight.viewed && (
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse" />
)} )}
</div> </div>
<ConnectionDiagram <ConnectionDiagram
note1Title={insight.note1.title || t('homeDashboard.untitled')} note1Title={pathNoteTitle(insight.note1.title, insight.note1Excerpt) || t('homeDashboard.untitled')}
note2Title={insight.note2.title || t('homeDashboard.untitled')} note2Title={pathNoteTitle(insight.note2.title, insight.note2Excerpt) || t('homeDashboard.untitled')}
score={insight.score} score={insight.score}
/> />
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1"> <p className="text-sm text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
« {text} » « {text} »
</p> </p>
{excerpt && ( {excerpt && (
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2"> <p className="text-sm text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-brand-accent/20 ps-2">
{excerpt} {excerpt}
</p> </p>
)} )}
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap"> <div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap shrink-0">
<button <button
type="button" type="button"
onClick={() => onOpenInsightNote(insight, insight.note1.id)} onClick={() => onOpenInsightNote(insight, insight.note1.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors" className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
> >
<ExternalLink size={9} /> <ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')} {t('homeDashboard.intelOpenNote')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)} onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors" className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-brand-accent/40 transition-colors"
> >
<GitCompare size={9} /> <GitCompare size={9} />
{t('homeDashboard.intelCompare')} {t('homeDashboard.intelCompare')}
@@ -358,19 +364,19 @@ export function IntelligenceHub({
case 'bridge': { case 'bridge': {
const { bridge } = item const { bridge } = item
const title = bridge.note?.title || t('homeDashboard.untitled') const title = pathNoteTitle(bridge.note?.title, bridge.note?.content) || t('homeDashboard.untitled')
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : '' const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
const names = bridge.clusterNames ?? [] const names = bridge.clusterNames ?? []
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 mb-3"> <div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap size={12} className="text-ochre" /> <Zap size={12} className="text-brand-accent" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre"> <span className="text-[13px] font-medium text-brand-accent">
{t('homeDashboard.bridgeNote')} {t('homeDashboard.bridgeNote')}
</span> </span>
</div> </div>
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full"> <span className="text-[13px] font-medium text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded-full">
{Math.round(bridge.bridgeScore * 100)}% {Math.round(bridge.bridgeScore * 100)}%
</span> </span>
</div> </div>
@@ -379,11 +385,11 @@ export function IntelligenceHub({
onClick={() => onNoteSelect(bridge.noteId)} onClick={() => onNoteSelect(bridge.noteId)}
className="text-start group flex-1" className="text-start group flex-1"
> >
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug"> <p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors mb-2 leading-snug">
{title} {title}
</p> </p>
{excerpt && ( {excerpt && (
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p> <p className="text-sm text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
)} )}
{names.length > 0 && ( {names.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -396,7 +402,7 @@ export function IntelligenceHub({
className="w-1.5 h-1.5 rounded-full" className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }} style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
/> />
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span> <span className="text-[13px] text-concrete">{name}</span>
</span> </span>
))} ))}
</div> </div>
@@ -405,7 +411,7 @@ export function IntelligenceHub({
<button <button
type="button" type="button"
onClick={() => onNoteSelect(bridge.noteId)} onClick={() => onNoteSelect(bridge.noteId)}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit" className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent/90 text-white hover:bg-brand-accent transition-colors w-fit"
> >
<ExternalLink size={9} /> <ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')} {t('homeDashboard.intelOpenNote')}
@@ -421,28 +427,28 @@ export function IntelligenceHub({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Lightbulb size={12} className="text-violet-500" /> <Lightbulb size={12} className="text-brand-accent" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate"> <span className="text-[13px] font-medium text-brand-accent truncate">
{t('homeDashboard.intelMissingLink')} {t('homeDashboard.intelMissingLink')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]"> <span className="px-2 py-1 rounded-lg bg-brand-accent/10 text-[13px] font-medium text-brand-accent truncate max-w-[45%]" title={suggestion.clusterAName}>
{suggestion.clusterAName} {suggestion.clusterAName}
</span> </span>
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" /> <div className="flex-1 h-px bg-gradient-to-r from-brand-accent/40 via-brand-accent/60 to-brand-accent/40" />
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]"> <span className="px-2 py-1 rounded-lg bg-brand-accent/10 text-[13px] font-medium text-brand-accent truncate max-w-[45%]" title={suggestion.clusterBName}>
{suggestion.clusterBName} {suggestion.clusterBName}
</span> </span>
</div> </div>
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p> <p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p> <p className="text-sm text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15"> <div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15 shrink-0">
<button <button
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => onCreateBridgeSuggestion(suggestion)} onClick={() => onCreateBridgeSuggestion(suggestion)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40" className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
> >
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />} {busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
{t('homeDashboard.createBridgeNote')} {t('homeDashboard.createBridgeNote')}
@@ -451,7 +457,7 @@ export function IntelligenceHub({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => onDismissBridgeSuggestion(suggestion)} onClick={() => onDismissBridgeSuggestion(suggestion)}
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40" className="text-[13px] font-medium px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
> >
{t('homeDashboard.dismiss')} {t('homeDashboard.dismiss')}
</button> </button>
@@ -465,25 +471,25 @@ export function IntelligenceHub({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Bot size={12} className="text-ochre" /> <Bot size={12} className="text-brand-accent" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre"> <span className="text-[13px] font-medium text-brand-accent">
{agent.agentName} {agent.agentName}
</span> </span>
<span className="text-[8px] font-mono text-concrete/60 ms-auto"> <span className="text-[13px] text-concrete/60 ms-auto">
{formatRelativeTime(agent.createdAt, t)} {formatRelativeTime(agent.createdAt, t)}
</span> </span>
</div> </div>
{agent.result ? ( {agent.result ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1"> <p className="text-sm text-concrete leading-relaxed line-clamp-4 flex-1">
{agent.result} {agent.result}
</p> </p>
) : ( ) : (
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p> <p className="text-sm text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
)} )}
<button <button
type="button" type="button"
onClick={() => router.push('/agents')} onClick={() => router.push('/agents')}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit" className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg border border-brand-accent/30 text-brand-accent hover:bg-brand-accent/10 transition-colors w-fit"
> >
{t('homeDashboard.intelViewAgent')} {t('homeDashboard.intelViewAgent')}
<ArrowRight size={9} /> <ArrowRight size={9} />
@@ -497,10 +503,10 @@ export function IntelligenceHub({
const spotlightAccent = (item: IntelItem | null) => { const spotlightAccent = (item: IntelItem | null) => {
if (!item) return 'from-stone-100/50 to-transparent border-border/30' if (!item) return 'from-stone-100/50 to-transparent border-border/30'
switch (item.kind) { switch (item.kind) {
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25' case 'insight': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25' case 'bridge': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25' case 'suggestion': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30' case 'agent': return 'from-brand-accent/[0.04] via-transparent to-transparent border-border/30'
} }
} }
@@ -518,7 +524,7 @@ export function IntelligenceHub({
actions={( actions={(
<> <>
{newCount > 0 && ( {newCount > 0 && (
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase"> <span className="text-[13px] font-medium text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded">
{newCount} {t('homeDashboard.new')} {newCount} {t('homeDashboard.new')}
</span> </span>
)} )}
@@ -543,7 +549,7 @@ export function IntelligenceHub({
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" /> <div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
) : !aiActive ? ( ) : !aiActive ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center"> <div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<p className="text-xs text-concrete leading-relaxed"> <p className="text-sm text-concrete leading-relaxed">
{!hasAiConsent {!hasAiConsent
? t('homeDashboard.aiConsentRequired') ? t('homeDashboard.aiConsentRequired')
: !providerReady : !providerReady
@@ -554,7 +560,7 @@ export function IntelligenceHub({
<button <button
type="button" type="button"
onClick={onEnableAi} onClick={onEnableAi}
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto" className="text-[13px] font-medium px-3 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors mx-auto"
> >
{t('homeDashboard.enableAi')} {t('homeDashboard.enableAi')}
</button> </button>
@@ -563,12 +569,12 @@ export function IntelligenceHub({
) : allItems.length === 0 ? ( ) : allItems.length === 0 ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center"> <div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} /> <Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p> <p className="text-sm text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
<button <button
type="button" type="button"
onClick={onRefreshEcho} onClick={onRefreshEcho}
disabled={echoRefreshing} disabled={echoRefreshing}
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto" className="inline-flex items-center gap-1.5 text-[13px] font-medium px-3 py-1.5 rounded-lg border border-brand-accent/30 text-brand-accent hover:bg-brand-accent/10 transition-all disabled:opacity-40 mx-auto"
> >
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />} {echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{t('homeDashboard.analyzeNotes')} {t('homeDashboard.analyzeNotes')}
@@ -587,15 +593,15 @@ export function IntelligenceHub({
key={f.key} key={f.key}
type="button" type="button"
onClick={() => setFilter(f.key)} onClick={() => setFilter(f.key)}
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${ className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[13px] font-medium transition-all ${
active active
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm' ? 'bg-brand-accent text-white shadow-sm'
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink' : 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
}`} }`}
> >
{f.label} {f.label}
{count > 0 && ( {count > 0 && (
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}> <span className={`text-[13px] px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{count} {count}
</span> </span>
)} )}
@@ -605,7 +611,7 @@ export function IntelligenceHub({
</div> </div>
{filteredItems.length === 0 ? ( {filteredItems.length === 0 ? (
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p> <p className="text-sm text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
) : ( ) : (
<> <>
{/* Spotlight carousel */} {/* Spotlight carousel */}
@@ -616,7 +622,7 @@ export function IntelligenceHub({
type="button" type="button"
onClick={goPrev} onClick={goPrev}
disabled={activeIndex === 0} disabled={activeIndex === 0}
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all" className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-brand-accent/40 transition-all"
aria-label={t('homeDashboard.intelPrev')} aria-label={t('homeDashboard.intelPrev')}
> >
<ChevronLeft size={14} /> <ChevronLeft size={14} />
@@ -625,7 +631,7 @@ export function IntelligenceHub({
type="button" type="button"
onClick={goNext} onClick={goNext}
disabled={activeIndex >= filteredItems.length - 1} disabled={activeIndex >= filteredItems.length - 1}
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all" className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-brand-accent/40 transition-all"
aria-label={t('homeDashboard.intelNext')} aria-label={t('homeDashboard.intelNext')}
> >
<ChevronRight size={14} /> <ChevronRight size={14} />
@@ -664,14 +670,14 @@ export function IntelligenceHub({
onClick={() => setActiveIndex(idx)} onClick={() => setActiveIndex(idx)}
className={`rounded-full transition-all ${ className={`rounded-full transition-all ${
idx === activeIndex idx === activeIndex
? 'w-5 h-1.5 bg-ochre' ? 'w-5 h-1.5 bg-brand-accent'
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50' : 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
}`} }`}
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })} aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
/> />
))} ))}
</div> </div>
<span className="text-[8px] font-mono text-concrete/60 uppercase"> <span className="text-[13px] text-concrete/60">
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })} {t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
</span> </span>
</div> </div>
@@ -684,37 +690,39 @@ export function IntelligenceHub({
const isActive = idx === activeIndex const isActive = idx === activeIndex
let label = '' let label = ''
let Icon = Sparkles let Icon = Sparkles
let accent = 'text-indigo-500' let accent = 'text-brand-accent'
if (item.kind === 'insight') { if (item.kind === 'insight') {
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled') label = pathNoteTitle(item.insight.note1.title, item.insight.note1Excerpt) || t('homeDashboard.untitled')
Icon = Sparkles Icon = Sparkles
accent = 'text-indigo-500' accent = 'text-brand-accent'
} else if (item.kind === 'bridge') { } else if (item.kind === 'bridge') {
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled') label = pathNoteTitle(item.bridge.note?.title, item.bridge.note?.content) || t('homeDashboard.untitled')
Icon = Zap Icon = Zap
accent = 'text-ochre' accent = 'text-brand-accent'
} else if (item.kind === 'suggestion') { } else if (item.kind === 'suggestion') {
label = item.suggestion.suggestedTitle.slice(0, 28) label = item.suggestion.suggestedTitle
Icon = Lightbulb Icon = Lightbulb
accent = 'text-violet-500' accent = 'text-brand-accent'
} else { } else {
label = item.agent.agentName label = item.agent.agentName
Icon = Bot Icon = Bot
accent = 'text-ochre' accent = 'text-brand-accent'
} }
return ( return (
<button <button
key={item.id} key={item.id}
type="button" type="button"
onClick={() => setActiveIndex(idx)} onClick={() => setActiveIndex(idx)}
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${ title={label}
aria-label={label}
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[180px] transition-all ${
isActive isActive
? 'border-ochre/40 bg-ochre/5 shadow-sm' ? 'border-brand-accent/40 bg-brand-accent/5 shadow-sm'
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50' : 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
}`} }`}
> >
<Icon size={9} className={`shrink-0 ${accent}`} /> <Icon size={9} className={`shrink-0 ${accent}`} />
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span> <span className="text-[13px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
</button> </button>
) )
})} })}

View File

@@ -0,0 +1,81 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
/** Render node/callout label: plain lines + inline $KaTeX$. */
export function DemoRichLabel({
text,
className,
lit,
}: {
text: string
className?: string
lit?: boolean
}) {
const lines = useMemo(() => {
return text.split(/\\n|\n/).map((line) => {
const parts: Array<{ type: 'text' | 'math'; value: string }> = []
const re = /\$([^$]+)\$/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
if (m.index > last) {
parts.push({ type: 'text', value: line.slice(last, m.index) })
}
parts.push({ type: 'math', value: m[1] || '' })
last = m.index + m[0].length
}
if (last < line.length) parts.push({ type: 'text', value: line.slice(last) })
if (parts.length === 0) parts.push({ type: 'text', value: line })
return parts
})
}, [text])
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-0.5 text-center leading-snug',
className
)}
>
{lines.map((parts, i) => (
<div
key={i}
className={cn(
'flex flex-wrap items-center justify-center gap-x-1',
i === 0
? lit
? 'text-[13px] font-semibold tracking-tight'
: 'text-[12.5px] font-semibold tracking-tight'
: 'text-[12px] font-medium opacity-90'
)}
>
{parts.map((p, j) => {
if (p.type === 'math') {
let html = p.value
try {
html = katex.renderToString(p.value, {
displayMode: false,
throwOnError: false,
})
} catch {
/* keep raw */
}
return (
<span
key={j}
className="katex-node inline-block [&_.katex]:text-[0.95em]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
return <span key={j}>{p.value}</span>
})}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,971 @@
'use client'
import { useId, useMemo } from 'react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
badgeGlyph,
dimOpacity,
intentColor,
spotlightColor,
} from '@/lib/interactive-demo/intent-colors'
import type { DemoScene, Panel } from '@/lib/interactive-demo/types'
import type {
ResolvedAnnotation,
StepResolvedState,
} from '@/lib/interactive-demo/resolve'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label'
import { cn } from '@/lib/utils'
type AnnKind = ResolvedAnnotation['kind']
export type DemoSceneViewProps = {
scene: DemoScene
state: StepResolvedState
reducedMotion?: boolean
className?: string
}
type NodePos = {
x: number
y: number
w: number
h: number
}
function elementOpacity(
id: string,
state: StepResolvedState,
dim: number
): number {
// Overview = full scene at full brightness (matches edges behavior)
if (state.overview) return 1
if (!(id in state.revealed)) return 0
if (state.spotlight.length === 0) return 1
return state.spotlight.includes(id) ? 1 : dim
}
function estimateNodeSize(label: string): { w: number; h: number } {
const lines = label.split(/\\n|\n/)
const hasMath = /\$/.test(label)
const visualLen = (s: string) =>
s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45))))
.length
const longest = Math.max(...lines.map(visualLen), 6)
const w = Math.min(280, Math.max(128, longest * 7.4 + 40))
const h = Math.max(
hasMath ? 58 : 48,
30 + lines.length * (hasMath ? 24 : 18)
)
return { w, h }
}
/** Prefer circular layout when edges form a closed loop covering most nodes. */
function findCycleOrder(
ids: string[],
edges: { from: string; to: string }[]
): string[] | null {
if (ids.length < 3) return null
const idSet = new Set(ids)
const outs = new Map<string, string[]>()
for (const id of ids) outs.set(id, [])
for (const e of edges) {
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
outs.get(e.from)!.push(e.to)
}
for (const start of ids) {
const path = [start]
const seen = new Set([start])
let cur = start
while (path.length < ids.length) {
const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t))
if (nexts.length === 0) break
// Prefer continuing a simple cycle (single unused neighbor)
const n = nexts[0]!
path.push(n)
seen.add(n)
cur = n
}
const closes = (outs.get(cur) ?? []).includes(start)
if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) {
return path
}
}
return null
}
/**
* Layout strategies:
* 1. AttnRes trunk pattern (residualTrunk + layers)
* 2. Closed cycle → circular
* 3. DAG layered (top → bottom)
* 4. Fallback grid
*/
function layoutNodes(
nodes: { id: string; label?: string }[],
edges: { from: string; to: string }[]
): { positions: Map<string, NodePos>; width: number; height: number } {
const sizes = new Map(
nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
)
const positions = new Map<string, NodePos>()
const padX = 40
const padY = 36
const gapX = 52
const gapY = 32
const trunk = nodes.find((n) => n.id === 'residualTrunk')
const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk')
if (trunk && layersOnly.length >= 2) {
const layerSizes = layersOnly.map((n) => sizes.get(n.id)!)
const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96)
const trunkSize = sizes.get(trunk.id)!
const contentH =
padY * 2 +
layerSizes.reduce((acc, s) => acc + s.h, 0) +
gapY * Math.max(0, layersOnly.length - 1)
const height = Math.max(240, contentH)
const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24
let y = padY
layersOnly.forEach((n) => {
const s = sizes.get(n.id)!
positions.set(n.id, {
x: padX + maxLayerW / 2,
y: y + s.h / 2,
w: s.w,
h: s.h,
})
y += s.h + gapY
})
positions.set(trunk.id, {
x: padX + maxLayerW + gapX + trunkSize.w / 2,
y: height / 2,
w: trunkSize.w,
h: trunkSize.h,
})
return { positions, width, height }
}
const cycle = findCycleOrder(
nodes.map((n) => n.id),
edges
)
if (cycle) {
const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128)
const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48)
const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35)
const leftovers = nodes.filter((n) => !cycle.includes(n.id))
const leftoverRowH = leftovers.length ? maxH + gapY : 0
const width = Math.max(
420,
padX * 2 + radius * 2 + maxW,
padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1)
)
const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH)
const cx = width / 2
const cy = padY + radius + maxH / 2
cycle.forEach((id, i) => {
const s = sizes.get(id)!
const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length
positions.set(id, {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
w: s.w,
h: s.h,
})
})
// Leftover nodes: horizontal row BELOW the ring (never overlapping it)
if (leftovers.length) {
const totalW =
leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) +
gapX * (leftovers.length - 1)
let x = cx - totalW / 2
const y = cy + radius + maxH / 2 + gapY + maxH / 2
for (const n of leftovers) {
const s = sizes.get(n.id)!
positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h })
x += s.w + gapX
}
}
return { positions, width, height }
}
// Topological layers (sources at top)
const ids = nodes.map((n) => n.id)
const idSet = new Set(ids)
const indeg = new Map(ids.map((id) => [id, 0]))
const outs = new Map(ids.map((id) => [id, [] as string[]]))
for (const e of edges) {
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1)
outs.get(e.from)!.push(e.to)
}
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0)
const order: string[] = []
const depth = new Map<string, number>()
queue.forEach((id) => depth.set(id, 0))
const q = [...queue]
while (q.length) {
const u = q.shift()!
order.push(u)
for (const v of outs.get(u) ?? []) {
depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1))
indeg.set(v, (indeg.get(v) ?? 1) - 1)
if ((indeg.get(v) ?? 0) === 0) q.push(v)
}
}
const isDag = order.length === ids.length && edges.length > 0
if (isDag) {
const byDepth = new Map<number, string[]>()
for (const id of ids) {
const d = depth.get(id) ?? 0
if (!byDepth.has(d)) byDepth.set(d, [])
byDepth.get(d)!.push(id)
}
const maxDepth = Math.max(...byDepth.keys(), 0)
const rowHeights: number[] = []
let width = padX * 2
for (let d = 0; d <= maxDepth; d++) {
const row = byDepth.get(d) ?? []
const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36)
rowHeights.push(rowH)
const rowW =
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
gapX * Math.max(0, row.length - 1)
width = Math.max(width, padX * 2 + rowW)
}
const height =
padY * 2 +
rowHeights.reduce((a, b) => a + b, 0) +
gapY * Math.max(0, maxDepth)
let y = padY
for (let d = 0; d <= maxDepth; d++) {
const row = byDepth.get(d) ?? []
const rowH = rowHeights[d]!
const rowW =
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
gapX * Math.max(0, row.length - 1)
let x = (width - rowW) / 2
for (const id of row) {
const s = sizes.get(id)!
positions.set(id, {
x: x + s.w / 2,
y: y + rowH / 2,
w: s.w,
h: s.h,
})
x += s.w + gapX
}
y += rowH + gapY
}
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
}
// Grid fallback
const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length))))
const rows = Math.ceil(nodes.length / cols)
const colW: number[] = Array.from({ length: cols }, (_, c) => {
let max = 120
nodes.forEach((n, i) => {
if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w)
})
return max
})
const rowH: number[] = Array.from({ length: rows }, (_, r) => {
let max = 48
nodes.forEach((n, i) => {
if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h)
})
return max
})
const width =
padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1)
const height =
padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1)
nodes.forEach((n, i) => {
const c = i % cols
const r = Math.floor(i / cols)
const s = sizes.get(n.id)!
const xOff =
padX +
colW.slice(0, c).reduce((a, b) => a + b, 0) +
gapX * c +
colW[c]! / 2
const yOff =
padY +
rowH.slice(0, r).reduce((a, b) => a + b, 0) +
gapY * r +
rowH[r]! / 2
positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h })
})
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
}
/** Border intersection: line from center A → center B, clipped to rect A. */
function borderPoint(
from: NodePos,
to: NodePos
): { x: number; y: number } {
const dx = to.x - from.x
const dy = to.y - from.y
if (dx === 0 && dy === 0) return { x: from.x, y: from.y }
const hw = from.w / 2
const hh = from.h / 2
const ax = Math.abs(dx) / (hw || 1)
const ay = Math.abs(dy) / (hh || 1)
const t = 1 / Math.max(ax, ay)
return { x: from.x + dx * t, y: from.y + dy * t }
}
function edgePath(a: NodePos, b: NodePos, tipInset = 10): string {
const p0 = borderPoint(a, b)
const p1 = borderPoint(b, a)
const dx = p1.x - p0.x
const dy = p1.y - p0.y
const len = Math.hypot(dx, dy) || 1
// Stop short of the target so markerEnd tip lands on the node border, not under the card
const inset = Math.min(tipInset, len * 0.35)
const endX = p1.x - (dx / len) * inset
const endY = p1.y - (dy / len) * inset
const mx = (p0.x + endX) / 2
const my = (p0.y + endY) / 2
const bend = Math.min(36, len * 0.22)
const cx = mx - (dy / len) * bend
const cy = my + (dx / len) * bend
return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}`
}
function AnnotationsOverlay({
annotations,
getAnchor,
dark,
markerId,
shadowId,
}: {
annotations: ResolvedAnnotation[]
getAnchor: (
id: string,
kind: AnnKind
) => { x: number; y: number } | null
dark: boolean
markerId: string
shadowId?: string
}) {
const outline = spotlightColor(dark)
return (
<g className="demo-annotations" pointerEvents="none">
{annotations.map((ann, i) => {
const target = ann.targetIds[0]
if (!target) return null
const pos = getAnchor(target, ann.kind)
if (!pos) return null
const color = intentColor(ann.intent, dark)
const key = `${ann.kind}-${target}-${i}`
if (ann.kind === 'circle') {
return (
<circle
key={key}
cx={pos.x}
cy={pos.y}
r={22}
fill="none"
stroke={color}
strokeWidth={2.5}
opacity={0.9}
/>
)
}
if (ann.kind === 'badge') {
const n = ann.badgeIndex ?? i + 1
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<circle r={11} fill={outline} opacity={0.95} />
<text
textAnchor="middle"
dominantBaseline="central"
fill="#fff"
fontSize={11}
fontWeight={700}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{badgeGlyph(n)}
</text>
</g>
)
}
if (ann.kind === 'callout') {
const text = ann.text ?? ''
const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<rect
x={8}
y={-14}
width={w}
height={28}
rx={8}
fill={dark ? '#1a1a1a' : '#ffffff'}
stroke={color}
strokeWidth={1.5}
filter={shadowId ? `url(#${shadowId})` : undefined}
/>
<text
x={18}
y={5}
fontSize={12}
fontWeight={500}
fill={dark ? '#f0f0f0' : '#1a1a1a'}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{text}
</text>
</g>
)
}
if (ann.kind === 'arrow') {
return (
<path
key={key}
d={`M ${pos.x - 28} ${pos.y - 28} L ${pos.x - 6} ${pos.y - 6}`}
stroke={color}
strokeWidth={2.5}
strokeLinecap="round"
markerEnd={`url(#${markerId})`}
fill="none"
/>
)
}
return null
})}
</g>
)
}
function SvgScenePanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'svg-scene' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const uid = useId().replace(/:/g, '')
const nodes = panel.payload.nodes
const edges = panel.payload.edges ?? []
const dim = dimOpacity(dark)
const outline = spotlightColor(dark)
const markerId = `demo-arrow-${uid}`
const dotsId = `demo-dots-${uid}`
const { positions, width, height } = useMemo(
() => layoutNodes(nodes, edges),
[nodes, edges]
)
const transition = reducedMotion
? 'none'
: 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease'
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)'
const boardBg = dark ? '#0c0e12' : '#f7f5f0'
return (
<div
className="relative w-full overflow-hidden"
style={{
background: boardBg,
aspectRatio: `${width} / ${height}`,
}}
>
<svg
viewBox={`0 0 ${width} ${height}`}
className="absolute inset-0 h-full w-full"
aria-hidden
>
<defs>
<pattern
id={dotsId}
width="18"
height="18"
patternUnits="userSpaceOnUse"
>
<circle cx="1.2" cy="1.2" r="1" fill={gridStroke} />
</pattern>
<marker
id={markerId}
markerWidth="10"
markerHeight="8"
refX="9"
refY="4"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L10,4 L0,8 Z" fill={outline} />
</marker>
</defs>
<rect width={width} height={height} fill={`url(#${dotsId})`} />
{edges.map((e) => {
const a = positions.get(e.from)
const b = positions.get(e.to)
if (!a || !b) return null
const revealed = e.id in state.revealed || state.overview
if (!revealed) return null
const op = elementOpacity(e.id, state, dim)
if (op === 0) return null
const lit = state.overview || state.spotlight.includes(e.id)
const stroke = intentColor(e.intent, dark)
const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45
return (
<path
key={e.id}
d={edgePath(a, b)}
stroke={stroke}
strokeWidth={widthStroke}
strokeDasharray={e.style === 'dashed' ? '7 5' : undefined}
strokeLinecap="round"
fill="none"
opacity={op}
markerEnd={`url(#${markerId})`}
style={{ transition }}
/>
)
})}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId={markerId}
getAnchor={(id, kind) => {
const p = positions.get(id)
if (!p) return null
if (kind === 'badge') {
return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 }
}
if (kind === 'callout') {
return { x: p.x + p.w / 2 - 4, y: p.y }
}
return { x: p.x, y: p.y }
}}
/>
</svg>
{nodes.map((n) => {
const p = positions.get(n.id)
if (!p) return null
const op = elementOpacity(n.id, state, dim)
if (op === 0) return null
const lit = state.overview || state.spotlight.includes(n.id)
const accent = intentColor(n.intent, dark)
const label = n.label ?? n.id
return (
<div
key={n.id}
className={cn(
'absolute flex flex-col justify-center rounded-2xl px-3 py-2.5 backdrop-blur-[2px]',
dark ? 'bg-[#141820]/ee text-zinc-50' : 'bg-white/95 text-zinc-900'
)}
style={{
left: `${((p.x - p.w / 2) / width) * 100}%`,
top: `${((p.y - p.h / 2) / height) * 100}%`,
width: `${(p.w / width) * 100}%`,
minHeight: `${(p.h / height) * 100}%`,
opacity: op,
borderStyle: 'solid',
borderTopWidth: lit ? 2 : 1.5,
borderRightWidth: lit ? 2 : 1.5,
borderBottomWidth: lit ? 2 : 1.5,
borderLeftWidth: 5,
borderTopColor: lit ? outline : accent,
borderRightColor: lit ? outline : accent,
borderBottomColor: lit ? outline : accent,
borderLeftColor: accent,
boxShadow: lit
? `0 0 0 3px ${outline}33, 0 10px 28px -8px ${outline}66`
: dark
? '0 8px 24px -10px rgba(0,0,0,0.65)'
: '0 8px 22px -10px rgba(15,23,42,0.18)',
transition,
}}
>
<DemoRichLabel text={label} lit={lit} />
</div>
)
})}
</div>
)
}
function ChartPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'chart' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const series = panel.payload.series
const dim = dimOpacity(dark)
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
const data = useMemo(() => {
return Array.from({ length: maxLen }, (_, i) => {
const row: Record<string, number | string> = { i: String(i + 1) }
for (const s of series) {
if (!(s.id in state.revealed)) continue
row[s.id] = s.values[i] ?? 0
}
return row
})
}, [maxLen, series, state.revealed])
const Chart =
panel.payload.chartType === 'bar'
? BarChart
: panel.payload.chartType === 'area'
? AreaChart
: LineChart
return (
<div className="w-full h-52 min-h-[13rem] min-w-0">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 12, right: 16, left: 4, bottom: 8 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis
dataKey="i"
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
width={40}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
borderRadius: 10,
border: '1px solid var(--border)',
background: dark ? '#18181b' : '#fff',
fontSize: 12,
}}
/>
{series.map((s) => {
if (!(s.id in state.revealed)) return null
const op = elementOpacity(s.id, state, dim)
const stroke = intentColor(s.intent, dark)
if (panel.payload.chartType === 'bar') {
return (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
fillOpacity={op}
radius={[4, 4, 0, 0]}
isAnimationActive={!reducedMotion}
/>
)
}
if (panel.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={op * 0.22}
strokeOpacity={op}
strokeWidth={2}
isAnimationActive={!reducedMotion}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeOpacity={op}
strokeWidth={2.5}
dot={false}
isAnimationActive={!reducedMotion}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
)
}
function HeatmapPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'heatmap-matrix' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload
const cell = 34
const labelW = 44
const labelH = 26
const w = labelW + cols * cell + 8
const h = labelH + rows * cell + 8
const transition = reducedMotion ? 'none' : 'opacity 220ms ease'
const dim = dimOpacity(dark)
const fillIntent = spotlightColor(dark)
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
// Intensity ∝ value — normalized so real-world scales (not just [0,1]) work
const maxAbsV = Math.max(
1e-9,
...values.flat().map((v) => Math.abs(v))
)
const cellRect = (r: number, c: number) => ({
x: labelW + (c - 1) * cell,
y: labelH + (r - 1) * cell,
})
const positions = useMemo(() => {
const map = new Map<
string,
{ cx: number; cy: number; trX: number; trY: number }
>()
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
if (triangular === 'lower' && c > r) continue
if (triangular === 'upper' && c < r) continue
const id = `r${r}.c${c}`
const { x, y } = cellRect(r, c)
map.set(id, {
cx: x + cell / 2,
cy: y + cell / 2,
trX: x + cell - 2,
trY: y + 2,
})
}
}
return map
}, [rows, cols, triangular])
const uid = useId().replace(/:/g, '')
const markerId = `demo-arrowhead-hm-${uid}`
return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
<defs>
<marker
id={markerId}
markerWidth="9"
markerHeight="9"
refX="7"
refY="3.5"
orient="auto"
>
<path d="M0,0 L7,3.5 L0,7 Z" fill={fillIntent} />
</marker>
</defs>
{colLabels?.map((lab, i) => (
<text
key={`c-${i}`}
x={labelW + i * cell + cell / 2}
y={labelH / 2 + 1}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{rowLabels?.map((lab, i) => (
<text
key={`r-${i}`}
x={labelW / 2}
y={labelH + i * cell + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{Array.from({ length: rows }, (_, ri) =>
Array.from({ length: cols }, (_, ci) => {
const r = ri + 1
const c = ci + 1
if (triangular === 'lower' && c > r) return null
if (triangular === 'upper' && c < r) return null
const id = `r${r}.c${c}`
const { x, y } = cellRect(r, c)
const revealed = id in state.revealed
const v = values[ri]?.[ci] ?? 0
const lit = state.overview || state.spotlight.includes(id)
const op = revealed ? elementOpacity(id, state, dim) : 1
if (!revealed) {
return (
<rect
key={id}
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill="none"
stroke={ghostStroke}
strokeWidth={1}
strokeDasharray="3 2"
opacity={0.8}
/>
)
}
const vNorm = Math.min(1, Math.abs(v) / maxAbsV)
const fillOpacity = 0.14 + vNorm * 0.78
const textFill =
vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
return (
<g key={id} opacity={op} style={{ transition }}>
<rect
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill={fillIntent}
fillOpacity={fillOpacity}
stroke={lit ? fillIntent : 'transparent'}
strokeWidth={lit ? 2 : 0}
/>
<text
x={x + cell / 2}
y={y + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontWeight={600}
fill={textFill}
>
{v.toFixed(2)}
</text>
</g>
)
})
)}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId={markerId}
getAnchor={(id, kind) => {
const p = positions.get(id)
if (!p) return null
if (kind === 'badge') return { x: p.trX, y: p.trY }
if (kind === 'callout') return { x: p.trX + 4, y: p.cy }
return { x: p.cx, y: p.cy }
}}
/>
</svg>
)
}
export function DemoSceneView({
scene,
state,
reducedMotion,
className,
}: DemoSceneViewProps) {
const dark = useDarkMode()
return (
<div
className={cn(
'grid gap-4',
scene.panels.length === 2 ? 'md:grid-cols-2' : 'grid-cols-1',
className
)}
>
{scene.panels.map((panel) => (
<div
key={panel.id}
className={cn(
'overflow-hidden rounded-xl border shadow-sm',
dark
? 'border-white/10 bg-[#0f1115]'
: 'border-black/10 bg-[#faf9f6]'
)}
>
{panel.type === 'svg-scene' && (
<SvgScenePanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
)}
{panel.type === 'chart' && (
<div className="p-3">
<ChartPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
{panel.type === 'heatmap-matrix' && (
<div className="p-3">
<HeatmapPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import katex from 'katex'
import { marked } from 'marked'
import { sanitizeRichHtml } from '@/lib/sanitize-content'
import 'katex/dist/katex.min.css'
/**
* Render demo `speak`: light markdown + inline $KaTeX$ via the note pipeline pieces.
*/
export function DemoSpeak({ speak, className }: { speak: string; className?: string }) {
const html = useMemo(() => {
const placeholders: string[] = []
const withSlots = speak.replace(/\$([^$]+)\$/g, (_, tex: string) => {
const i = placeholders.length
try {
placeholders.push(
katex.renderToString(tex, { displayMode: false, throwOnError: false })
)
} catch {
placeholders.push(tex)
}
return `%%KATEX${i}%%`
})
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
md = md.replace(`%%KATEX${i}%%`, () => frag)
})
return sanitizeRichHtml(md)
}, [speak])
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function useDarkMode(): boolean {
const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: false
)
useEffect(() => {
const check = () =>
setIsDark(document.documentElement.classList.contains('dark'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => obs.disconnect()
}, [])
return isDark
}

View File

@@ -0,0 +1,387 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Pause,
Play,
RotateCcw,
SkipBack,
SkipForward,
StepBack,
StepForward,
} from 'lucide-react'
import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
import {
resolveInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
const SPEEDS = [0.5, 1, 2, 4] as const
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const ACT_CHANGE_PAUSE_MS = 800
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
function stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
return clamped / speed
}
export type InteractiveDemoPlayerProps = {
demo: InteractiveDemoV1
mode?: 'interactive' | 'static'
className?: string
}
export function InteractiveDemoPlayer({
demo,
mode = 'interactive',
className,
}: InteractiveDemoPlayerProps) {
const { t } = useLanguage()
const reducedMotion = usePrefersReducedMotion()
const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
const [actIndex, setActIndex] = useState(0)
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speedIdx, setSpeedIdx] = useState(1)
const speed = SPEEDS[speedIdx] ?? 1
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const actPauseRef = useRef(false)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const act = resolved.acts[actIndex] ?? resolved.acts[0]
const scenes = useMemo(() => {
let scene = demo.scene
const map: (typeof demo.scene)[] = []
for (const a of demo.acts) {
if (a.scene) scene = a.scene
map.push(scene)
}
return map
}, [demo])
const scene = scenes[actIndex] ?? demo.scene
const state =
mode === 'static'
? act?.final
: act?.steps[stepIndex] ?? act?.final
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const goStep = useCallback(
(next: number) => {
if (!act) return
if (next < 0) {
setStepIndex(0)
return
}
if (next >= act.steps.length) {
setPlaying(false)
setStepIndex(act.steps.length - 1)
return
}
setStepIndex(next)
},
[act]
)
const resetAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setStepIndex(0)
}, [clearTimer])
const resetDemo = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setActIndex(0)
setStepIndex(0)
}, [clearTimer])
const skipAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex >= resolved.acts.length - 1) {
setStepIndex((act?.steps.length ?? 1) - 1)
return
}
setActIndex((i) => i + 1)
setStepIndex(0)
}, [actIndex, act, resolved.acts.length, clearTimer])
const prevAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex <= 0) {
setStepIndex(0)
return
}
setActIndex((i) => i - 1)
setStepIndex(0)
}, [actIndex, clearTimer])
// Auto-play: duration recalculates immediately when speed/step changes
useEffect(() => {
clearTimer()
if (!playing || mode !== 'interactive' || !act || !state) return
const atLastStep = stepIndex >= act.steps.length - 1
const ms = stepDurationMs(state.speak, speed)
timerRef.current = setTimeout(() => {
if (atLastStep) {
// End of act while playing
if (actIndex < resolved.acts.length - 1) {
actPauseRef.current = true
timerRef.current = setTimeout(() => {
actPauseRef.current = false
setActIndex((i) => i + 1)
setStepIndex(0)
}, ACT_CHANGE_PAUSE_MS)
} else {
// End of demo — pause on final state
setPlaying(false)
}
return
}
setStepIndex((s) => s + 1)
}, ms)
return clearTimer
}, [
playing,
stepIndex,
speed,
act,
state,
mode,
actIndex,
resolved.acts.length,
clearTimer,
])
useEffect(() => {
if (mode !== 'interactive') return
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(e.target as HTMLElement)?.isContentEditable
) {
return
}
// Multi-player pages: only the hovered/focused player answers the keyboard
if (!activeRef.current) return
if (e.code === 'Space') {
e.preventDefault()
setPlaying((p) => !p)
} else if (e.code === 'ArrowRight' && e.shiftKey) {
e.preventDefault()
skipAct()
} else if (e.code === 'ArrowLeft' && e.shiftKey) {
e.preventDefault()
prevAct()
} else if (e.code === 'ArrowRight') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex + 1)
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex - 1)
} else if (e.code === 'KeyR' && e.shiftKey) {
e.preventDefault()
resetDemo()
} else if (e.code === 'KeyR') {
e.preventDefault()
resetAct()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
if (!act || !state || !scene) {
return (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
</div>
)
}
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
return (
<div
className={cn(
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
className
)}
data-demo-id={demo.id}
data-mode={mode}
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
{demo.disclaimer && (
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
{demo.disclaimer}
</p>
)}
<div className="p-3 sm:p-4">
<DemoSceneView
scene={scene}
state={state}
reducedMotion={reducedMotion || mode === 'static'}
/>
</div>
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
<DemoSpeak
speak={state.speak}
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
/>
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
{progressLabel}
</span>
</div>
{mode === 'interactive' && (
<div className="flex flex-wrap items-center gap-2 pb-2">
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex - 1)
}}
aria-label="Previous step"
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex + 1)
}}
aria-label="Next step"
>
<StepForward className="h-4 w-4" />
</button>
<div className="mx-0.5 h-5 w-px bg-border" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={prevAct}
aria-label="Previous act"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={skipAct}
aria-label="Next act"
>
<SkipForward className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={resetAct}
aria-label="Reset act"
title="R"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
role="group"
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
>
{SPEEDS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setSpeedIdx(i)}
aria-pressed={speedIdx === i}
className={cn(
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
speedIdx === i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{s}×
</button>
))}
</div>
</div>
)}
</div>
<noscript>
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
{act.steps.map((s) => (
<li key={s.stepId}>{s.speak}</li>
))}
</ol>
</noscript>
</div>
)
}

View File

@@ -0,0 +1,412 @@
'use client'
import { useEffect, useState } from 'react'
import { Clapperboard, Loader2, Globe } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { PageView } from '@/components/interactive-page/page-view'
import {
generateInteractivePage,
generateInteractivePagePlan,
generateInteractivePageSection,
type PagePlan,
} from '@/lib/ai/services/interactive-page-client.service'
import {
validateInteractivePage,
type PageSection,
type PageSpecV1,
} from '@/lib/interactive-page'
import { useLanguage } from '@/lib/i18n'
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
function slugId(title: string): string {
const s = title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
return s ? `page.${s}` : 'page.generated'
}
/** Degraded section when its LLM call failed — page still completes. */
function fallbackSection(
sectionId: string,
plan: PagePlan['sections'][number],
lang: string
): PageSection {
const fr = lang.startsWith('fr')
return {
id: sectionId,
title: plan.title,
blocks: [
{ type: 'prose', md: plan.goal },
{
type: 'callout',
kind: 'note',
title: fr ? 'En bref' : 'In short',
md: plan.demoGoal || plan.goal,
},
],
}
}
function assemblePage(
plan: PagePlan,
sections: PageSection[],
lang: string
): PageSpecV1 {
return {
schemaVersion: 1,
id: slugId(plan.heroTitle),
lang,
hero: {
kicker: lang.startsWith('fr')
? 'EXPLAINER INTERACTIF'
: 'INTERACTIVE EXPLAINER',
title: plan.heroTitle,
subtitle: plan.heroSubtitle,
meta: lang.startsWith('fr')
? 'Généré depuis votre note'
: 'Generated from your note',
},
overview: {
lead: plan.overviewLead,
cards: plan.overviewCards.map((c) => ({
badge: c.badge,
title: c.title,
body: c.body,
intent: c.intent as any,
})),
},
sections,
}
}
/**
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
* → validate → preview → publish (pageSpec snapshot, no double quota).
* Falls back to the deterministic page when the LLM path is unavailable.
*/
export function InteractivePagePublishDialog({
open,
onOpenChange,
noteId,
content,
language,
onPublished,
}: {
open: boolean
onOpenChange: (open: boolean) => void
noteId: string
content: string
language: string
onPublished: (slug: string) => void
}) {
const { t } = useLanguage()
const [phase, setPhase] = useState<Phase>('idle')
const [page, setPage] = useState<PageSpecV1 | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<string | null>(null)
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!open || phase !== 'generating') {
if (!open) setElapsedSec(0)
return
}
const t0 = Date.now()
const id = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
}, 500)
return () => window.clearInterval(id)
}, [open, phase])
useEffect(() => {
if (!open) {
setPhase('idle')
setPage(null)
setError(null)
setProgress(null)
return
}
// Guard: empty content from editor race (aligned with API min 40 words)
const wordCount = content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 40) {
setPhase('error')
setError(
t('richTextEditor.publishInteractivePageTooShort') ||
'Note trop courte — ajoutez du contenu puis réessayez'
)
return
}
let cancelled = false
const run = async () => {
setPhase('generating')
setError(null)
setPage(null)
const legacyFallback = async (notice?: string) => {
const result = await generateInteractivePage({
content,
lang: language,
noteId,
})
if (cancelled) return
if (!result.ok) {
setPhase('error')
setError(
result.reason ||
result.error ||
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la page interactive'
)
if (result.quotaExceeded) {
toast.error(t('ai.quotaExceeded'))
}
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
if (notice) toast.info(notice)
setPage(result.page)
setPhase('preview')
}
// 1. LLM plan (billed)
setProgress(
t('richTextEditor.publishInteractivePagePlanning') ||
'Analyse du contenu — plan de la page…'
)
const planResult = await generateInteractivePagePlan({
content,
lang: language,
noteId,
})
if (cancelled) return
if (!planResult.ok) {
if (planResult.quotaExceeded) {
setPhase('error')
setError(planResult.error)
toast.error(t('ai.quotaExceeded'))
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
if (planResult.error === 'unsuitable_content') {
setPhase('error')
setError(
planResult.reason ||
t('richTextEditor.publishInteractivePageFailed') ||
'Contenu inadapté'
)
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
// LLM plan unavailable → deterministic full page
await legacyFallback(
t('richTextEditor.publishInteractivePageFallback') ||
'Génération IA indisponible — page simplifiée affichée'
)
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
// 2. One LLM call per section, with real progress
const plan = planResult.plan
const sections: PageSection[] = []
let degraded = 0
for (let i = 0; i < plan.sections.length; i++) {
const planSection = plan.sections[i]
const sectionId = `s${i + 1}`
setProgress(
(
t('richTextEditor.publishInteractivePageSectionProgress') ||
'Section {current}/{total} : {title}'
)
.replace('{current}', String(i + 1))
.replace('{total}', String(plan.sections.length))
.replace('{title}', planSection.title)
)
const sectionResult = await generateInteractivePageSection({
content,
lang: language,
noteId,
pageTitle: plan.heroTitle,
sectionId,
section: planSection,
})
if (cancelled) return
if (sectionResult.ok) {
sections.push(sectionResult.section)
} else {
degraded += 1
sections.push(fallbackSection(sectionId, planSection, language))
}
}
// 3. Assemble + hard validation client-side
const candidate = assemblePage(plan, sections, language)
const validated = validateInteractivePage(candidate)
if (!validated.ok) {
await legacyFallback(
t('richTextEditor.publishInteractivePageFallback') ||
'Génération IA indisponible — page simplifiée affichée'
)
return
}
if (degraded > 0) {
toast.info(
t('richTextEditor.publishInteractivePagePartialFallback') ||
'Certaines sections ont été générées en mode simplifié'
)
}
setPage(validated.page)
setPhase('preview')
}
void run()
return () => {
cancelled = true
}
// Intentionally omit `t` — unstable identity cancels in-flight generation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, content, language, noteId])
const handlePublish = async () => {
if (!page || phase === 'publishing') return
setPhase('publishing')
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
action: 'publish',
mode: 'interactive-page',
template: 'interactive-page',
language,
pageSpec: page,
}),
})
const data = await res.json()
if (!res.ok) {
toast.error(
data.reason ||
data.error ||
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la publication'
)
setPhase('preview')
return
}
toast.success(
t('richTextEditor.publishInteractivePageSuccess') ||
'Page interactive publiée !'
)
onPublished(data.slug)
onOpenChange(false)
} catch {
toast.error(
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la publication'
)
setPhase('preview')
}
}
const generatingHint =
progress ||
(elapsedSec < 15
? t('richTextEditor.publishInteractivePageGenerating') ||
'Génération de la page…'
: elapsedSec < 40
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
'Construction des sections et démos…'
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle className="flex items-center gap-2 text-base">
<Clapperboard className="h-4 w-4 text-brand-accent" />
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</DialogTitle>
<DialogDescription className="text-xs">
{phase === 'generating'
? generatingHint
: t('richTextEditor.publishInteractivePagePreviewHint') ||
'Aperçu — vérifiez puis publiez sur lURL publique'}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
{phase === 'generating' ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
<p>{generatingHint}</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
{elapsedSec}s
</p>
</div>
) : null}
{phase === 'error' ? (
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
<p className="font-medium">
{t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la page interactive'}
</p>
<p className="mt-2 text-muted-foreground">{error}</p>
</div>
) : null}
{phase === 'preview' || phase === 'publishing' ? (
page ? <PageView page={page} demoMode="interactive" /> : null
) : null}
</div>
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={phase === 'publishing'}
>
{t('general.cancel') || 'Annuler'}
</Button>
<Button
type="button"
onClick={handlePublish}
disabled={phase !== 'preview' || !page}
className="gap-2"
>
{phase === 'publishing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
{t('richTextEditor.publishInteractivePageConfirm') ||
'Publier la page'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
import { AlertCircle } from 'lucide-react'
const STRINGS = {
fr: {
stale: 'Le contenu source a évolué — cette page interactive est à régénérer.',
invalid: 'Page interactive indisponible',
},
en: {
stale: 'The source content has changed — this interactive page needs regeneration.',
invalid: 'Interactive page unavailable',
},
}
/**
* Public / preview shell for published interactive pages.
* Parses stored PageSpecV1 JSON from `publishedContent`.
*/
export function InteractivePublishedPage({
publishedContent,
isStale,
}: {
publishedContent: string
isStale?: boolean
}) {
let page: PageSpecV1 | null = null
let error: string | null = null
let lang = 'fr'
try {
const raw = JSON.parse(publishedContent)
const result = validateInteractivePage(raw)
if (result.ok) {
page = result.page
lang = result.page.lang || 'fr'
} else {
error = result.issues[0]?.message || 'PageSpec invalide'
}
} catch {
error = 'JSON de page interactive illisible'
}
const t = lang.startsWith('fr') ? STRINGS.fr : STRINGS.en
if (!page) {
return (
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p>{error || t.invalid}</p>
</div>
)
}
return (
<div>
{isStale ? (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
{t.stale}
</div>
) : null}
<PageView page={page} demoMode="interactive" />
</div>
)
}

View File

@@ -0,0 +1,391 @@
'use client'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
import { SimBlockView } from '@/components/interactive-page/sim-block'
import { StepsBlockView } from '@/components/interactive-page/steps-block'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
import type { PageBlock } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
const set = new Set<IntentId>()
const collectFromPanels = (panels: InteractiveDemoV1['scene']['panels']) => {
for (const panel of panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
}
collectFromPanels(demo.scene.panels)
for (const act of demo.acts) {
if (act.scene) collectFromPanels(act.scene.panels)
for (const step of act.steps) {
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
}
}
return [...set]
}
const DEMO_INTENT_LABELS: Record<IntentId, { fr: string; en: string }> = {
highlight: { fr: 'Focus', en: 'Focus' },
flow: { fr: 'Flux', en: 'Flow' },
cache: { fr: 'Mémoire', en: 'Memory' },
compute: { fr: 'Calcul', en: 'Compute' },
output: { fr: 'Résultat', en: 'Result' },
warning: { fr: 'Attention', en: 'Warning' },
}
function DemoLegend({ demo, lang }: { demo: InteractiveDemoV1; lang: string }) {
const dark = useDarkMode()
const fr = lang.startsWith('fr')
const intents = collectDemoIntents(demo)
if (!intents.length) return null
return (
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{fr ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => (
<span key={id} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: intentColor(id, dark) }}
/>
{fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
</span>
))}
</div>
)
}
const CALLOUT_STYLES: Record<
string,
{ border: string; bg: string; badge: string }
> = {
definition: {
border: 'border-sky-500/30',
bg: 'bg-sky-500/5',
badge: 'text-sky-700 dark:text-sky-300',
},
warning: {
border: 'border-amber-500/35',
bg: 'bg-amber-500/5',
badge: 'text-amber-800 dark:text-amber-300',
},
tip: {
border: 'border-emerald-500/30',
bg: 'bg-emerald-500/5',
badge: 'text-emerald-800 dark:text-emerald-300',
},
note: {
border: 'border-border',
bg: 'bg-muted/40',
badge: 'text-muted-foreground',
},
}
function IntentBadge({
label,
intent,
}: {
label: string
intent?: IntentId
}) {
const dark = useDarkMode()
const color = intentColor(intent, dark)
return (
<span
className="inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
style={{
color,
backgroundColor: `${color}22`,
border: `1px solid ${color}44`,
}}
>
{label}
</span>
)
}
function ChartBlockView({
block,
}: {
block: Extract<PageBlock, { type: 'chart' }>
}) {
const dark = useDarkMode()
const series = block.payload.series
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
const data = Array.from({ length: maxLen }, (_, i) => {
const row: Record<string, number | string> = { i: String(i + 1) }
for (const s of series) row[s.id] = s.values[i] ?? 0
return row
})
const Chart =
block.payload.chartType === 'bar'
? BarChart
: block.payload.chartType === 'area'
? AreaChart
: LineChart
return (
<figure
className="my-6 rounded-xl border p-4"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="h-56 min-h-[14rem] min-w-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis dataKey="i" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} width={36} axisLine={false} tickLine={false} />
<Tooltip />
{series.map((s) => {
const stroke = intentColor(s.intent, dark)
if (block.payload.chartType === 'bar') {
return (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
)
}
if (block.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={0.22}
strokeWidth={2}
isAnimationActive={false}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
{block.caption ? (
<figcaption className="mt-3 text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
export function PageBlockView({
block,
demoMode = 'static',
lang = 'fr',
}: {
block: PageBlock
demoMode?: 'interactive' | 'static'
lang?: string
}) {
if (block.type === 'prose') {
return <PageMd md={block.md} className="my-4 text-[17px] leading-[1.7]" />
}
if (block.type === 'formula') {
return <PageFormula tex={block.tex} caption={block.caption} />
}
if (block.type === 'callout') {
const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
return (
<aside
className={cn(
'my-5 rounded-xl border px-4 py-3.5 shadow-sm',
style.border,
style.bg
)}
>
<p
className={cn(
'mb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]',
style.badge
)}
>
{block.kind} · {block.title}
</p>
<PageMd md={block.md} className="text-[15px] [&_p]:my-1" />
</aside>
)
}
if (block.type === 'demo') {
return (
<figure className="my-8">
{block.caption ? (
<p className="mb-3 text-[15px] leading-relaxed text-muted-foreground">
{block.caption}
</p>
) : null}
<InteractiveDemoPlayer demo={block.demo} mode={demoMode} />
<DemoLegend demo={block.demo} lang={lang} />
{demoMode === 'static' ? (
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm text-muted-foreground">
{block.demo.acts.flatMap((a) =>
a.steps.map((s) => (
<li key={s.id}>
<strong>{a.title}</strong> {s.speak}
</li>
))
)}
</ol>
</noscript>
) : null}
</figure>
)
}
if (block.type === 'sim') {
return <SimBlockView block={block} lang={lang} />
}
if (block.type === 'steps') {
return <StepsBlockView block={block} lang={lang} />
}
if (block.type === 'chart') {
return <ChartBlockView block={block} />
}
if (block.type === 'stats') {
return (
<div className="my-8 grid grid-cols-2 gap-3 md:grid-cols-4">
{block.items.map((item, i) => (
<div
key={`${item.label}-${i}`}
className="rounded-[14px] border px-4 py-3.5"
style={{ background: 'var(--pp-paper)', borderColor: 'var(--pp-line)' }}
>
<p
className="font-extrabold leading-tight tabular-nums"
style={{
fontSize: 'clamp(22px, 2.4vw, 30px)',
color: 'var(--pp-plum)',
}}
>
{item.value}
</p>
<p className="mt-1.5 text-[13px] leading-snug" style={{ color: 'var(--pp-ink)' }}>
{item.label}
</p>
</div>
))}
</div>
)
}
if (block.type === 'table') {
return (
<figure
className="my-6 overflow-x-auto rounded-xl border"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<table className="w-full min-w-[460px] border-collapse text-[13.5px]">
{block.caption ? (
<caption
className="px-3 py-2.5 text-left text-xs"
style={{ fontFamily: 'var(--pp-mono)', color: 'var(--pp-muted)' }}
>
{block.caption}
</caption>
) : null}
<thead>
<tr style={{ borderBottom: '1px solid var(--pp-line)', background: 'var(--pp-paper)' }}>
{block.columns.map((c) => (
<th
key={c}
className="px-3 py-2.5 text-left font-semibold tracking-tight"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr
key={ri}
style={{ borderBottom: ri < block.rows.length - 1 ? '1px solid var(--pp-line)' : undefined }}
className="last:border-0"
>
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-2.5 align-top">
<PageMd md={cell} className="text-sm [&_p]:my-0" />
</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
)
}
if (block.type === 'image') {
return (
<figure className="my-6">
{ }
<img
src={block.src}
alt={block.alt}
className="mx-auto max-h-[480px] w-auto max-w-full rounded-xl border border-border/50 shadow-sm"
/>
{block.caption ? (
<figcaption className="mt-2 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
return null
}
export { IntentBadge }

View File

@@ -0,0 +1,92 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import { marked } from 'marked'
import { sanitizeRichHtml } from '@/lib/sanitize-content'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
/** Light markdown + inline $KaTeX$ for page prose / callouts / speak. */
export function PageMd({
md,
className,
}: {
md: string
className?: string
}) {
const html = useMemo(() => {
const placeholders: string[] = []
const withSlots = md.replace(/\$([^$]+)\$/g, (_, tex: string) => {
const i = placeholders.length
try {
placeholders.push(
katex.renderToString(tex, { displayMode: false, throwOnError: false })
)
} catch {
placeholders.push(tex)
}
return `%%KATEX${i}%%`
})
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
// function replacement — $', $`, $& in KaTeX HTML must not be interpreted
out = out.replace(`%%KATEX${i}%%`, () => frag)
})
return sanitizeRichHtml(out)
}, [md])
return (
<div
className={cn(
'prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:tracking-tight',
className
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function PageFormula({
tex,
caption,
}: {
tex: string
caption?: string
}) {
const html = useMemo(() => {
try {
return katex.renderToString(tex, {
displayMode: true,
throwOnError: false,
})
} catch {
return tex
}
}, [tex])
return (
<figure
className="my-5 overflow-x-auto rounded-xl border px-4 py-4 md:px-5"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
borderLeft: '4px solid var(--pp-plum)',
}}
>
<div
className="text-[1.15em] [&_.katex-display]:m-0"
dangerouslySetInnerHTML={{ __html: html }}
/>
{caption ? (
<figcaption
className="mt-2.5 text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{caption}
</figcaption>
) : null}
</figure>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId } from '@/lib/interactive-demo/types'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { cn } from '@/lib/utils'
function collectIntents(page: PageSpecV1): IntentId[] {
const set = new Set<IntentId>()
for (const card of page.overview?.cards ?? []) {
if (card.intent) set.add(card.intent)
}
for (const section of page.sections) {
for (const block of section.blocks) {
if (block.type === 'stats') {
for (const item of block.items) {
if (item.intent) set.add(item.intent)
}
}
if (block.type === 'chart') {
for (const s of block.payload.series) {
if (s.intent) set.add(s.intent)
}
}
if (block.type === 'demo') {
const panels = [
...block.demo.scene.panels,
...block.demo.acts.flatMap((a) => a.scene?.panels ?? []),
]
for (const panel of panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
}
}
}
return [...set]
}
const INTENT_LABELS_FR: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flux',
cache: 'Mémoire',
compute: 'Calcul',
output: 'Résultat',
warning: 'Attention',
}
const INTENT_LABELS_EN: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flow',
cache: 'Memory',
compute: 'Compute',
output: 'Result',
warning: 'Warning',
}
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
const [active, setActive] = useState(page.sections[0]?.id ?? '')
const dark = useDarkMode()
const intents = useMemo(() => collectIntents(page), [page])
useEffect(() => {
const nodes = page.sections
.map((s) => document.getElementById(s.id))
.filter(Boolean) as HTMLElement[]
if (!nodes.length) return
const obs = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
const top = visible[0]?.target?.id
if (top) setActive(top)
},
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [page.sections])
return (
<div
className="sticky top-0 z-30 backdrop-blur-sm"
style={{
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
borderTop: '1px solid var(--pp-line)',
borderBottom: '1px solid var(--pp-line)',
}}
>
<nav
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
aria-label="Sections"
style={{ fontFamily: 'var(--pp-mono)' }}
>
{page.sections.map((s, i) => (
<a
key={s.id}
href={`#${s.id}`}
className={cn(
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
active === s.id ? 'font-semibold' : 'hover:opacity-80'
)}
style={
active === s.id
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{i + 1} · {s.title}
</a>
))}
</nav>
{intents.length > 0 ? (
<div
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
style={{
borderTop: '1px solid var(--pp-line)',
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-muted)',
}}
>
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => {
const color = intentColor(id, dark)
return (
<span
key={id}
className="inline-flex items-center gap-1.5"
>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
{page.lang.startsWith('fr')
? INTENT_LABELS_FR[id]
: INTENT_LABELS_EN[id]}
</span>
)
})}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,282 @@
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
import { PageMd } from '@/components/interactive-page/page-md'
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
export type PageViewProps = {
page: PageSpecV1
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
demoMode?: 'interactive' | 'static'
className?: string
paper?: boolean
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
function useScrollReveal(enabled: boolean) {
useEffect(() => {
if (!enabled) {
document
.querySelectorAll<HTMLElement>('[data-scroll-init]')
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
return
}
const nodes = Array.from(
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
)
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.setAttribute('data-scroll-visible', 'true')
obs.unobserve(e.target)
}
}
},
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [enabled])
}
/**
* PageView — Kimi/AttnRes-style explainer page.
* Design tokens replicated from the reference page (paper, plum accent,
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
*/
export function PageView({
page,
demoMode = 'interactive',
className,
paper = true,
}: PageViewProps) {
const reducedMotion = usePrefersReducedMotion()
useScrollReveal(!reducedMotion)
const paperStyle = {
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
} as CSSProperties
return (
<article
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
data-page-id={page.id}
style={paperStyle}
>
<style>{`
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
.interactive-page {
--pp-paper: #F4F0E8;
--pp-paper-deep: #EAE4D9;
--pp-card: #FFFDF8;
--pp-ink: #242422;
--pp-muted: #686762;
--pp-line: #D6D0C6;
--pp-plum: #9F3F70;
--pp-plum-soft: #F2DCE8;
--pp-blue: #3F6F9F;
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--pp-ink);
}
.dark .interactive-page {
--pp-paper: #17140F;
--pp-paper-deep: #201C15;
--pp-card: #221E17;
--pp-ink: #EDE7DB;
--pp-muted: #A39C8D;
--pp-line: #3B352A;
--pp-plum: #D07AA6;
--pp-plum-soft: #3A2530;
--pp-blue: #7FA8CC;
}
.interactive-page.page-paper {
background-color: var(--pp-paper);
background-image: radial-gradient(
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
transparent 0.7px
);
background-size: 18px 18px;
}
.pp-card {
background: var(--pp-card);
border: 1px solid var(--pp-line);
border-radius: 14px;
}
.pp-mono {
font-family: var(--pp-mono);
}
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
opacity: 0;
transform: translateY(12px);
}
.interactive-page [data-scroll-init] {
transition: opacity 420ms ease, transform 420ms ease;
will-change: opacity, transform;
}
.interactive-page [data-scroll-visible] {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
`}</style>
{/* Without JS the scroll-reveal never fires — show everything */}
<noscript>
<style>{`
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
}
`}</style>
</noscript>
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
<p
className="pp-mono mb-2.5 text-xs uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
>
{page.hero.kicker}
</p>
<h1
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
>
{page.hero.title}
</h1>
{page.hero.subtitle ? (
<p
className="mt-2.5 font-semibold"
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
>
{page.hero.subtitle}
</p>
) : null}
{page.hero.meta ? (
<p
className="pp-mono mt-3 leading-relaxed"
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
>
{page.hero.meta}
</p>
) : null}
</header>
<PageStickyNav page={page} />
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
{/* ── One-minute overview ── */}
{page.overview ? (
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
<p
className="pp-mono mb-3 text-[11px] uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
>
{page.lang.startsWith('fr')
? 'Lessentiel en une minute'
: 'One-minute overview'}
</p>
<PageMd
md={page.overview.lead}
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
/>
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{page.overview.cards.map((card) => (
<div
key={card.badge + card.title}
className="rounded-xl border p-4"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
}}
>
<IntentBadge label={card.badge} intent={card.intent} />
<h3 className="mt-3 text-base font-semibold tracking-tight">
{card.title}
</h3>
<PageMd
md={card.body}
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
/>
</div>
))}
</div>
</section>
) : null}
{/* ── Sections ── */}
<div className="space-y-20">
{page.sections.map((section, i) => (
<section
key={section.id}
id={section.id}
className="scroll-mt-28"
data-scroll-init
>
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
<span
className="pp-mono text-sm font-semibold"
style={{ color: 'var(--pp-plum)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
{section.title}
</h2>
<div>
{section.blocks.map((block, bi) => {
const narrow =
block.type === 'prose' ||
block.type === 'formula' ||
block.type === 'callout'
return (
<div
key={`${section.id}-${bi}`}
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
>
<PageBlockView
block={block}
demoMode={demoMode}
lang={page.lang}
/>
</div>
)
})}
</div>
</section>
))}
</div>
{page.footer ? (
<footer
className="pp-mono mt-16 border-t pt-5 text-xs italic"
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
>
{page.footer}
</footer>
) : null}
</div>
</article>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { GENERIC_SIM_ID, getPlugin } from '@/lib/simulators'
import { ANIM_VIEWS, SIMULATOR_VIEWS } from '@/components/simulators'
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
import { GenericFormulaView } from '@/components/simulators/generic-formula-view'
import type { SimBlock } from '@/lib/interactive-page'
/** Renders a `sim` block: catalog plugin (bespoke view) or generic formula. */
export function SimBlockView({
block,
lang,
}: {
block: SimBlock
lang: string
}) {
const sim = block.sim
const fr = lang.startsWith('fr')
const plugin = sim.simId === GENERIC_SIM_ID ? null : getPlugin(sim.simId)
const kindLabel =
plugin?.family === 'anim'
? fr
? 'Animation interactive'
: 'Interactive animation'
: fr
? 'Simulation interactive'
: 'Interactive simulation'
let title: string | undefined
let body: React.ReactNode = null
if (sim.simId === GENERIC_SIM_ID) {
title = sim.title
body = (
<GenericFormulaView
sim={sim as Extract<typeof sim, { simId: 'generic-formula' }>}
lang={lang}
/>
)
} else {
if (!plugin) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
if (plugin.family === 'anim') {
const AnimScene = ANIM_VIEWS[sim.simId]
if (!AnimScene) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
</div>
)
}
body = (
<AnimPlayerShell beats={plugin.beats} lang={lang} disclaimer={plugin.disclaimer}>
{(stepIndex) => <AnimScene step={stepIndex} lang={lang} />}
</AnimPlayerShell>
)
} else {
const View = SIMULATOR_VIEWS[sim.simId]
if (!View) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
const preset = (sim as { preset?: Record<string, number> }).preset
body = (
<View
preset={preset}
title={title}
disclaimer={sim.disclaimer}
lang={lang}
/>
)
}
}
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{kindLabel}
</span>
</div>
{body}
{block.caption ? (
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
import type { StepsBlock } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
function renderTex(tex: string, displayMode: boolean): string {
try {
return katex.renderToString(tex, { displayMode, throwOnError: false })
} catch {
return tex
}
}
/**
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
* line by line, current line highlighted, transformation rule in the margin.
* No boxes, no LLM drawing — pure KaTeX + deterministic chrome.
*/
export function StepsBlockView({
block,
lang,
}: {
block: StepsBlock
lang: string
}) {
const fr = lang.startsWith('fr')
const steps = block.steps
const beats = useMemo(
() =>
steps.map((s, i) => ({
id: `st${i + 1}`,
speak: {
fr: s.speak || s.rule || (fr ? `Étape ${i + 1}` : `Step ${i + 1}`),
en: s.speak || s.rule || `Step ${i + 1}`,
},
})),
[steps, fr]
)
const rendered = useMemo(() => steps.map((s) => renderTex(s.tex, true)), [steps])
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
{block.title ? (
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{block.title}</h3>
<span
className="text-[10px] font-semibold uppercase tracking-[0.16em]"
style={{ color: 'var(--pp-muted)' }}
>
{fr ? 'Dérivation pas à pas' : 'Step-by-step derivation'}
</span>
</div>
) : null}
<AnimPlayerShell beats={beats} lang={lang}>
{(stepIndex) => (
<div className="space-y-1.5 p-3 md:p-4" style={{ background: 'var(--pp-paper)' }}>
{steps.slice(0, stepIndex + 1).map((s, i) => {
const current = i === stepIndex
return (
<div
key={i}
className={cn(
'flex items-start gap-3 rounded-lg border-l-4 px-3 py-2',
current ? 'shadow-sm' : 'border-transparent'
)}
style={{
borderLeftColor: current ? 'var(--pp-plum)' : 'transparent',
background: current ? 'var(--pp-card)' : 'transparent',
opacity: current ? 1 : 0.72,
transition: 'opacity 400ms ease, background 400ms ease',
}}
>
<span
className="mt-1 shrink-0 text-[10px] font-semibold tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
<div
className="min-w-0 flex-1 overflow-x-auto text-[1.02em] [&_.katex-display]:my-1"
dangerouslySetInnerHTML={{ __html: rendered[i] }}
/>
{s.rule ? (
<span
className="mt-0.5 shrink-0 rounded-md px-2 py-1 text-[11px] leading-snug"
style={{
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-plum)',
background: 'color-mix(in oklab, var(--pp-plum) 10%, transparent)',
maxWidth: '38%',
}}
>
{s.rule}
</span>
) : null}
</div>
)
})}
</div>
)}
</AnimPlayerShell>
{block.caption ? (
<figcaption
className="mt-3 text-center text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{block.caption}
</figcaption>
) : null}
{/* Without JS: full derivation visible + rules listed */}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-2 text-sm">
{steps.map((s, i) => (
<li key={i}>
<span dangerouslySetInnerHTML={{ __html: rendered[i] }} />
{s.rule ? <em className="ml-2 text-muted-foreground">({s.rule})</em> : null}
</li>
))}
</ol>
</noscript>
</figure>
)
}

View File

@@ -9,7 +9,9 @@ import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import type { SupportedLanguage } from '@/lib/i18n/load-translations' import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
@@ -38,6 +40,16 @@ export function LandingPage() {
const [langOpen, setLangOpen] = useState(false) const [langOpen, setLangOpen] = useState(false)
const [echoIndex, setEchoIndex] = useState(0) const [echoIndex, setEchoIndex] = useState(0)
const langRef = useRef<HTMLDivElement>(null) const langRef = useRef<HTMLDivElement>(null)
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; name: string }[] }>
},
staleTime: 60_000,
})
const byokProviders = byokCatalog?.providers ?? []
useEffect(() => { useEffect(() => {
if (!langOpen) return if (!langOpen) return
@@ -68,11 +80,30 @@ export function LandingPage() {
return () => { root.style.overflow = prev } return () => { root.style.overflow = prev }
}, [menuOpen]) }, [menuOpen])
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const PLANS = [ const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' }, { key: 'basic', popular: false, hasTrial: 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: 'pro',
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') }, 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: '',
},
] ]
const NAV = [ const NAV = [
@@ -82,6 +113,22 @@ export function LandingPage() {
{ href: '#pricing', label: t('landing.nav.pricing') }, { href: '#pricing', label: t('landing.nav.pricing') },
] ]
const scrollPublicHash = (hash: string) => {
const id = hash.replace(/^#/, '')
const target = document.getElementById(id)
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
if (!target || !root) return
const top = target.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - 88
root.scrollTo({ top, behavior: 'smooth' })
}
useEffect(() => {
const hash = window.location.hash
if (!hash) return
const id = window.requestAnimationFrame(() => scrollPublicHash(hash))
return () => window.cancelAnimationFrame(id)
}, [])
return ( return (
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white"> <div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
{/* Nav */} {/* Nav */}
@@ -94,7 +141,16 @@ export function LandingPage() {
</Link> </Link>
<div className="hidden lg:flex items-center gap-8"> <div className="hidden lg:flex items-center gap-8">
{NAV.map((l) => ( {NAV.map((l) => (
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors"> <a
key={l.href}
href={l.href}
onClick={(event) => {
event.preventDefault()
scrollPublicHash(l.href)
window.history.replaceState(null, '', l.href)
}}
className="text-[13px] text-white/75 hover:text-white transition-colors"
>
{l.label} {l.label}
</a> </a>
))} ))}
@@ -147,7 +203,7 @@ export function LandingPage() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2"> <Link href="/login" className="hidden sm:inline text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')} {t('landing.nav.login')}
</Link> </Link>
<Link <Link
@@ -180,13 +236,25 @@ export function LandingPage() {
<a <a
key={l.href} key={l.href}
href={l.href} href={l.href}
onClick={() => setMenuOpen(false)} onClick={(event) => {
event.preventDefault()
setMenuOpen(false)
scrollPublicHash(l.href)
window.history.replaceState(null, '', l.href)
}}
className="py-4 text-3xl font-serif border-b border-white/10" className="py-4 text-3xl font-serif border-b border-white/10"
> >
{l.label} {l.label}
</a> </a>
))} ))}
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold"> <Link
href="/login"
onClick={() => setMenuOpen(false)}
className="mt-10 py-4 text-2xl font-serif text-white/80 text-center border-b border-white/10"
>
{t('landing.nav.login')}
</Link>
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-4 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
{t('landing.nav.cta')} {t('landing.nav.cta')}
</Link> </Link>
</div> </div>
@@ -217,7 +285,7 @@ export function LandingPage() {
</span> </span>
</div> </div>
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium"> <p className="text-[13px] tracking-[0.12em] uppercase text-white/70 mb-5 font-medium">
{t('landing.hero.eyebrow')} {t('landing.hero.eyebrow')}
</p> </p>
@@ -227,7 +295,7 @@ export function LandingPage() {
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span> <span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
</h1> </h1>
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10"> <p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/75 leading-relaxed mb-10">
{t('landing.hero.subtitle')} {t('landing.hero.subtitle')}
</p> </p>
@@ -239,7 +307,7 @@ export function LandingPage() {
{t('landing.hero.cta')} {t('landing.hero.cta')}
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" /> <ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
</Link> </Link>
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p> <p className="text-[12px] text-white/60">{t('landing.hero.ctaHint')}</p>
</div> </div>
</div> </div>
@@ -287,7 +355,7 @@ export function LandingPage() {
{/* Trust strip */} {/* Trust strip */}
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]"> <section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide"> <div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/70 tracking-wide">
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => ( {['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
<span key={k} className="flex items-center gap-2"> <span key={k} className="flex items-center gap-2">
<Check size={14} className="text-[#D4A373]" /> <Check size={14} className="text-[#D4A373]" />
@@ -304,7 +372,7 @@ export function LandingPage() {
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6"> <h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
{t('landing.pain.title')} {t('landing.pain.title')}
</h2> </h2>
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p> <p className="text-lg text-white/70 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed"> <p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
{t('landing.pain.secondBrain')} {t('landing.pain.secondBrain')}
</p> </p>
@@ -346,7 +414,7 @@ export function LandingPage() {
<div className="grid grid-cols-2 gap-2 p-2"> <div className="grid grid-cols-2 gap-2 p-2">
{['w0', 'w1', 'w2', 'w3'].map((w) => ( {['w0', 'w1', 'w2', 'w3'].map((w) => (
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]"> <div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p> <p className="text-[13px] font-medium text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p> <p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
</div> </div>
))} ))}
@@ -363,7 +431,7 @@ export function LandingPage() {
> >
<div className="relative h-36 flex items-center justify-center"> <div className="relative h-36 flex items-center justify-center">
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} /> <Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35"> <p className="absolute bottom-2 left-4 text-[13px] font-medium text-white/75">
{t('landing.moments.insights.chip')} {t('landing.moments.insights.chip')}
</p> </p>
</div> </div>
@@ -379,7 +447,7 @@ export function LandingPage() {
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p> <p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span> <span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span> <span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[13px] font-semibold text-[#F4F1EA]">{t('landing.moments.revision.badge')}</span>
</div> </div>
</div> </div>
</ProductMoment> </ProductMoment>
@@ -398,7 +466,7 @@ export function LandingPage() {
<div key={s} className="relative text-center md:text-left"> <div key={s} className="relative text-center md:text-left">
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div> <div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3> <h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.desc`)}</p> <p className="text-sm text-white/70 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
</div> </div>
))} ))}
</div> </div>
@@ -411,14 +479,14 @@ export function LandingPage() {
<div className="max-w-2xl mb-14"> <div className="max-w-2xl mb-14">
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p> <p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2> <h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
<p className="text-white/50 text-lg leading-relaxed">{t('landing.agents.desc')}</p> <p className="text-white/70 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => ( {(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors"> <div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
<Bot size={18} className="text-[#D4A373] mb-4" /> <Bot size={18} className="text-[#D4A373] mb-4" />
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4> <h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p> <p className="text-sm text-white/70 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
</div> </div>
))} ))}
</div> </div>
@@ -434,15 +502,19 @@ export function LandingPage() {
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span> <span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
</div> </div>
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3> <h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p> <p className="text-white/70 leading-relaxed">{t('landing.byok.desc')}</p>
</div> </div>
<div className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5"> <div className="w-full md:w-[320px] text-[13px] text-white/70 space-y-4 rounded-2xl border border-white/10 bg-black/40 p-5">
<p className="text-[#D4A373]">{'{'}</p> {byokProviders.length > 0 ? (
<p className="pl-3">&quot;provider&quot;: &quot;anthropic&quot;,</p> <ul className="grid grid-cols-1 gap-2 text-sm text-[#F4F1EA]">
<p className="pl-3">&quot;model&quot;: &quot;claude-sonnet&quot;,</p> {byokProviders.map((p) => (
<p className="pl-3 text-[#F4F1EA]/70">&quot;apiKey&quot;: &quot;sk-ant-&quot;,</p> <li key={p.id}>{p.name}</li>
<p className="pl-3">&quot;yours&quot;: true</p> ))}
<p className="text-[#D4A373]">{'}'}</p> </ul>
) : (
<p className="text-white/70">{t('landing.byok.pointProvider')}</p>
)}
<p className="text-[#D4A373]">{t('landing.byok.pointYours')}</p>
</div> </div>
</div> </div>
</section> </section>
@@ -452,22 +524,24 @@ export function LandingPage() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2> <h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p> <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]"> <div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button <button
type="button" type="button"
onClick={() => setBillingInterval('monthly')} onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`} 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')} {t('landing.pricing.monthly')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setBillingInterval('annual')} 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/45'}`} 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')} {t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span> <span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button> </button>
</div> </div>
</div> </div>
@@ -486,20 +560,33 @@ export function LandingPage() {
{t('landing.pricing.popular')} {t('landing.pricing.popular')}
</span> </span>
)} )}
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2"> <h4 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
{t(`landing.pricing.${plan.key}.name`)} {t(`landing.pricing.${plan.key}.name`)}
</h4> </h4>
<div className="flex items-baseline gap-1 mb-2"> <div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span> <span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>} {plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
</div> </div>
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p> {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"> <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) => { {[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`) const feat = t(`landing.pricing.${plan.key}.feature${j}`, {
count: byokProviders.length || '…',
})
if (!feat || feat.startsWith('landing.')) return null if (!feat || feat.startsWith('landing.')) return null
return ( return (
<li key={j} className="flex gap-2 text-xs text-white/60"> <li key={j} className="flex gap-2 text-sm text-white/80">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" /> <Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat} {feat}
</li> </li>
@@ -508,13 +595,15 @@ export function LandingPage() {
</ul> </ul>
<Link <Link
href="/register" href="/register"
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${ className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
plan.popular plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white' ? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15' : 'bg-white/10 text-white hover:bg-white/15'
}`} }`}
> >
{t(`landing.pricing.${plan.key}.cta`)} {plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link> </Link>
</div> </div>
))} ))}
@@ -532,7 +621,7 @@ export function LandingPage() {
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6"> <h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
{t('landing.cta.title')} {t('landing.cta.title')}
</h2> </h2>
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p> <p className="text-white/70 text-lg mb-10">{t('landing.cta.desc')}</p>
<Link <Link
href="/register" href="/register"
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]" className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
@@ -540,7 +629,7 @@ export function LandingPage() {
{t('landing.cta.button')} {t('landing.cta.button')}
<ArrowRight size={18} /> <ArrowRight size={18} />
</Link> </Link>
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p> <p className="mt-4 text-[12px] text-white/75">{t('landing.hero.ctaHint')}</p>
</div> </div>
</section> </section>
@@ -553,15 +642,15 @@ export function LandingPage() {
</div> </div>
<span className="font-serif text-lg">Memento</span> <span className="font-serif text-lg">Memento</span>
</div> </div>
<p className="text-sm text-white/35">{t('landing.footer.desc')}</p> <p className="text-sm text-white/60">{t('landing.footer.desc')}</p>
</div> </div>
<div className="grid grid-cols-3 gap-10 text-sm"> <div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => ( {(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}> <div key={section}>
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3"> <p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{t(`landing.footer.${section}.title`)} {t(`landing.footer.${section}.title`)}
</p> </p>
<ul className="space-y-2 text-white/50"> <ul className="space-y-2 text-white/70">
{[0, 1, 2].map((j) => { {[0, 1, 2].map((j) => {
const label = t(`landing.footer.${section}.link${j}`) const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`) const href = t(`landing.footer.${section}.link${j}Href`)
@@ -581,7 +670,7 @@ export function LandingPage() {
))} ))}
</div> </div>
</div> </div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide"> <p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/70 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')} © 2026 Memento. {t('landing.footer.rights')}
</p> </p>
</footer> </footer>
@@ -624,7 +713,7 @@ function ProductMoment({
{eyebrow} {eyebrow}
</p> </p>
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3> <h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p> <p className={`text-[15px] leading-relaxed ${dark ? 'text-white/70' : 'text-[#0B0A09]/75'}`}>{desc}</p>
</div> </div>
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}> <div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
<div className="w-full">{children}</div> <div className="w-full">{children}</div>

View File

@@ -1,12 +1,15 @@
'use client'; 'use client';
import { useActionState } from 'react'; import { useActionState, useRef, useState, Suspense } from 'react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth'; import { authenticate } from '@/app/actions/auth';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react'; import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useLanguage } from '@/lib/i18n'; import { useLanguage } from '@/lib/i18n';
import { GoogleSignInButton } from '@/components/google-sign-in-button'; import { GoogleSignInButton } from '@/components/google-sign-in-button';
import { resendSignupVerification } from '@/app/actions/auth-verify';
import { toast } from 'sonner';
function AuthDivider({ label }: { label: string }) { function AuthDivider({ label }: { label: string }) {
return ( return (
@@ -41,7 +44,7 @@ function LoginButton() {
); );
} }
export function LoginForm({ function LoginFormInner({
allowRegister = true, allowRegister = true,
googleAuthEnabled = false, googleAuthEnabled = false,
authError, authError,
@@ -51,7 +54,11 @@ export function LoginForm({
authError?: string; authError?: string;
}) { }) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined); const [errorMessage, dispatch] = useActionState(authenticate, undefined);
const { t } = useLanguage(); const { t, language } = useLanguage();
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const [resending, setResending] = useState(false);
const verified = searchParams.get('verified') === '1';
const oauthError = const oauthError =
authError === 'SessionRequired' authError === 'SessionRequired'
? t('auth.sessionExpired') ? t('auth.sessionExpired')
@@ -59,6 +66,21 @@ export function LoginForm({
? t('auth.oauthAccountNotLinked') ? t('auth.oauthAccountNotLinked')
: authError ?? null; : authError ?? null;
const showUnverified = errorMessage === 'EMAIL_NOT_VERIFIED';
const handleResend = async () => {
const email = emailRef.current?.value?.trim() ?? '';
if (!email) {
toast.error(t('auth.verifyMissingEmail'));
return;
}
setResending(true);
const result = await resendSignupVerification(email, language);
setResending(false);
if (result.success) toast.success(t('auth.verifyResent'));
else toast.error(t('auth.verifyResendFailed'));
};
return ( 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="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"> <div className="space-y-8">
@@ -71,6 +93,12 @@ export function LoginForm({
</p> </p>
</div> </div>
{verified && (
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
{t('auth.emailVerifiedBanner')}
</p>
)}
{oauthError && ( {oauthError && (
<p className="text-sm text-red-500 text-center px-2" role="alert"> <p className="text-sm text-red-500 text-center px-2" role="alert">
{oauthError} {oauthError}
@@ -94,6 +122,7 @@ export function LoginForm({
<Mail size={16} /> <Mail size={16} />
</div> </div>
<input <input
ref={emailRef}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all" className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
id="email" id="email"
type="email" type="email"
@@ -136,13 +165,26 @@ export function LoginForm({
<LoginButton /> <LoginButton />
<div <div className="space-y-2" aria-live="polite" aria-atomic="true">
className="flex h-8 items-end space-x-1" {showUnverified && (
aria-live="polite" <div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
aria-atomic="true" <p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
> <button
{errorMessage && ( type="button"
<p className="text-sm text-red-500">{errorMessage}</p> onClick={handleResend}
disabled={resending}
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
>
{resending ? t('auth.sending') : t('auth.resendVerification')}
</button>
</div>
)}
{errorMessage && !showUnverified && (
<p className="text-sm text-red-500">
{errorMessage === 'Invalid credentials.'
? t('auth.invalidCredentials')
: errorMessage}
</p>
)} )}
</div> </div>
</form> </form>
@@ -164,3 +206,15 @@ export function LoginForm({
</div> </div>
); );
} }
export function LoginForm(props: {
allowRegister?: boolean;
googleAuthEnabled?: boolean;
authError?: string;
}) {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<LoginFormInner {...props} />
</Suspense>
);
}

View File

@@ -131,18 +131,18 @@ export function McpSettingsPanel({
<Info size={18} /> <Info size={18} />
</div> </div>
<div> <div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4> <h4 className="text-sm font-semibold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<p className="text-[11px] text-concrete leading-relaxed"> <p className="text-sm text-concrete leading-relaxed">
{t('mcpSettings.whatIsMcp.description')} {t('mcpSettings.whatIsMcp.description')}
</p> </p>
<a <a
href="https://modelcontextprotocol.io" href="https://modelcontextprotocol.io"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline mt-4" className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-accent hover:underline mt-4"
> >
{t('mcpSettings.whatIsMcp.learnMore')} {t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
@@ -156,21 +156,21 @@ export function McpSettingsPanel({
<Server size={18} /> <Server size={18} />
</div> </div>
<div> <div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.serverStatus.title')}</h4> <h4 className="text-sm font-semibold text-ink">{t('mcpSettings.serverStatus.title')}</h4>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.mode')}</span> <span className="text-sm text-concrete">{t('mcpSettings.serverStatus.mode')}</span>
<span className="text-[10px] font-bold text-ink uppercase tracking-widest bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border"> <span className="text-xs font-medium text-ink bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border">
{serverStatus.mode.toUpperCase()} {serverStatus.mode.toUpperCase()}
</span> </span>
</div> </div>
{serverStatus.mode === 'sse' && serverStatus.url && ( {serverStatus.mode === 'sse' && serverStatus.url && (
<div className="space-y-2"> <div className="space-y-2">
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.url')}</span> <span className="text-sm text-concrete">{t('mcpSettings.serverStatus.url')}</span>
<code className="text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink"> <code className="text-xs bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink">
{serverStatus.url} {serverStatus.url}
</code> </code>
</div> </div>
@@ -182,22 +182,22 @@ export function McpSettingsPanel({
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden"> <div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<div className="flex items-center justify-between p-6 border-b border-border/40"> <div className="flex items-center justify-between p-6 border-b border-border/40">
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"> <div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20">
<Key size={18} /> <Key size={18} />
</div> </div>
<div> <div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.apiKeys.title')}</h4> <h4 className="text-sm font-semibold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p> <p className="text-sm text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div> </div>
</div> </div>
{!mcpAllowed ? ( {!mcpAllowed ? (
<p className="text-[10px] text-concrete max-w-[200px] text-end leading-relaxed"> <p className="text-sm text-concrete max-w-[200px] text-end leading-relaxed">
{t('mcpSettings.tierRequired', { tier })} {t('mcpSettings.tierRequired', { tier })}
</p> </p>
) : ( ) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20"> <button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all">
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.generate')} {t('mcpSettings.apiKeys.generate')}
</button> </button>
@@ -209,7 +209,7 @@ export function McpSettingsPanel({
{!mcpAllowed && ( {!mcpAllowed && (
<div className="px-6 pb-4"> <div className="px-6 pb-4">
<p className="text-[11px] text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed"> <p className="text-sm text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
{t('mcpSettings.upgradeHint')} {t('mcpSettings.upgradeHint')}
</p> </p>
</div> </div>
@@ -219,7 +219,7 @@ export function McpSettingsPanel({
{keys.length === 0 ? ( {keys.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Key className="h-8 w-8 mx-auto mb-2 text-concrete opacity-30" /> <Key className="h-8 w-8 mx-auto mb-2 text-concrete opacity-30" />
<p className="text-[11px] text-concrete">{t('mcpSettings.apiKeys.empty')}</p> <p className="text-sm text-concrete">{t('mcpSettings.apiKeys.empty')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -243,7 +243,7 @@ export function McpSettingsPanel({
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label className="text-[10px] font-bold text-concrete uppercase tracking-widest">{rawKeyName}</Label> <Label className="text-sm text-concrete">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<code className="flex-1 text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl break-all font-mono border border-border text-ink"> <code className="flex-1 text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl break-all font-mono border border-border text-ink">
{showRawKey} {showRawKey}
@@ -260,7 +260,7 @@ export function McpSettingsPanel({
<DialogFooter> <DialogFooter>
<button <button
onClick={() => setShowRawKey(null)} onClick={() => setShowRawKey(null)}
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em]" className="px-6 py-2.5 rounded-xl bg-ink text-paper text-sm font-medium"
> >
{t('mcpSettings.createDialog.done')} {t('mcpSettings.createDialog.done')}
</button> </button>
@@ -283,7 +283,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label htmlFor="key-name" className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.createDialog.nameLabel')}</Label> <Label htmlFor="key-name" className="text-sm text-concrete">{t('mcpSettings.createDialog.nameLabel')}</Label>
<Input <Input
id="key-name" id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')} placeholder={t('mcpSettings.createDialog.namePlaceholder')}
@@ -297,7 +297,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
<button <button
onClick={() => onGenerate(name)} onClick={() => onGenerate(name)}
disabled={isPending} disabled={isPending}
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] disabled:opacity-60" className="px-6 py-2.5 rounded-xl bg-ink text-paper text-sm font-medium disabled:opacity-60"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />} {isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />}
@@ -321,9 +321,9 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
<div className="flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-paper/30 dark:bg-black/10"> <div className="flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-paper/30 dark:bg-black/10">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-bold text-ink">{keyInfo.name}</span> <span className="text-sm font-semibold text-ink">{keyInfo.name}</span>
<span className={cn( <span className={cn(
'text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-lg', 'text-xs font-medium px-2 py-0.5 rounded-lg',
keyInfo.active keyInfo.active
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300' ? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300'
: 'bg-concrete/10 text-concrete' : 'bg-concrete/10 text-concrete'
@@ -331,7 +331,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
{keyInfo.active ? t('mcpSettings.apiKeys.active') : t('mcpSettings.apiKeys.revoked')} {keyInfo.active ? t('mcpSettings.apiKeys.active') : t('mcpSettings.apiKeys.revoked')}
</span> </span>
</div> </div>
<div className="flex gap-4 text-[10px] text-concrete"> <div className="flex gap-4 text-sm text-concrete">
<span>{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}</span> <span>{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}</span>
<span>{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span> <span>{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span>
</div> </div>
@@ -341,7 +341,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
<button <button
onClick={() => onRevoke(keyInfo.shortId)} onClick={() => onRevoke(keyInfo.shortId)}
disabled={isPending} disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60" className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-sm font-medium text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60"
> >
<Ban className="h-3 w-3" /> <Ban className="h-3 w-3" />
{t('mcpSettings.apiKeys.revoke')} {t('mcpSettings.apiKeys.revoke')}
@@ -350,7 +350,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
<button <button
onClick={() => onDelete(keyInfo.shortId)} onClick={() => onDelete(keyInfo.shortId)}
disabled={isPending} disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold uppercase tracking-widest hover:bg-rose-500/20 transition-colors disabled:opacity-60" className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-sm font-medium hover:bg-rose-500/20 transition-colors disabled:opacity-60"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
{t('mcpSettings.apiKeys.delete')} {t('mcpSettings.apiKeys.delete')}
@@ -428,8 +428,8 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
<ExternalLink size={18} /> <ExternalLink size={18} />
</div> </div>
<div> <div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.configInstructions.title')}</h4> <h4 className="text-sm font-semibold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p> <p className="text-sm text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
</div> </div>
</div> </div>
<div className="p-6 space-y-3"> <div className="p-6 space-y-3">
@@ -439,7 +439,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-paper/50 dark:hover:bg-white/5 transition-colors" className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-paper/50 dark:hover:bg-white/5 transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)} onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
> >
<span className="text-[11px] font-bold text-ink">{cfg.title}</span> <span className="text-sm font-medium text-ink">{cfg.title}</span>
{expanded === cfg.id ? ( {expanded === cfg.id ? (
<ChevronDown className="h-4 w-4 text-concrete" /> <ChevronDown className="h-4 w-4 text-concrete" />
) : ( ) : (
@@ -448,7 +448,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
</button> </button>
{expanded === cfg.id && ( {expanded === cfg.id && (
<div className="px-5 pb-5"> <div className="px-5 pb-5">
<p className="text-[11px] text-concrete mb-3">{cfg.description}</p> <p className="text-sm text-concrete mb-3">{cfg.description}</p>
<div className="relative"> <div className="relative">
<pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border"> <pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border">
<code>{cfg.snippet}</code> <code>{cfg.snippet}</code>

View File

@@ -310,7 +310,6 @@ export function NetworkGraph({
.attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5) .attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5)
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none') .style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none')
// Labels de textes ultra-lisibles claire/sombre sans chevauchement
node.append('text') node.append('text')
.attr('dy', d => d.radius + 13) .attr('dy', d => d.radius + 13)
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')

View File

@@ -48,6 +48,8 @@ import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl' import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl' import { pl } from 'date-fns/locale/pl'
import { LabelBadge } from './label-badge' import { LabelBadge } from './label-badge'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import DOMPurify from 'isomorphic-dompurify' import DOMPurify from 'isomorphic-dompurify'
import { NoteImages } from './note-images' import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist' import { NoteChecklist } from './note-checklist'
@@ -308,6 +310,7 @@ export const NoteCard = memo(function NoteCard({
await deleteNote(note.id, { skipRevalidation: true }) await deleteNote(note.id, { skipRevalidation: true })
await refreshLabels() await refreshLabels()
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId }) emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
showNoteTrashedToast(note, t, () => setIsHidden(false))
} catch (error) { } catch (error) {
console.error('Failed to delete note:', error) console.error('Failed to delete note:', error)
setIsHidden(false) setIsHidden(false)
@@ -869,23 +872,11 @@ export const NoteCard = memo(function NoteCard({
</div> </div>
)} )}
{/* Delete Confirmation Dialog */} <ConfirmDeleteNoteDialog
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> open={showDeleteDialog}
<AlertDialogContent> onOpenChange={setShowDeleteDialog}
<AlertDialogHeader> onConfirm={handleDelete}
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle> />
<AlertDialogDescription>
{t('notes.confirmDelete')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
{t('notes.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Leave Share Confirmation Dialog */} {/* Leave Share Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}> <AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>

View File

@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) { export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const peekNoteId = searchParams.get('peekNote')
const { t, language } = useLanguage() const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar' const isRtl = language === 'fa' || language === 'ar'
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null) const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
const stripPeekFromUrl = useCallback(() => {
if (!searchParams.get('peekNote')) return
const params = new URLSearchParams(searchParams.toString())
params.delete('peekNote')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
useEffect(() => { useEffect(() => {
const onOpenPeek = (event: Event) => { const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
} }
}, [noteId, t]) }, [noteId, t])
// Dashboard « Comparer / Lier » : ouvrir la note liée à droite dès que léditeur est monté.
useEffect(() => {
if (!peekNoteId || peekNoteId === noteId) return
let cancelled = false
void getNoteById(peekNoteId).then((fetched) => {
if (cancelled) return
if (fetched) {
setPeekState(prev => (prev?.note.id === fetched.id ? prev : { note: fetched }))
} else {
toast.error(t('notePeek.loadFailed'))
}
})
return () => { cancelled = true }
}, [peekNoteId, noteId, t])
const handleClosePeek = useCallback(() => { const handleClosePeek = useCallback(() => {
setPeekState(null) setPeekState(null)
}, []) stripPeekFromUrl()
}, [stripPeekFromUrl])
const handleOpenPeekFully = useCallback(() => { const handleOpenPeekFully = useCallback(() => {
if (!peekState) return if (!peekState) return
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
})) }))
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id) params.set('openNote', peekState.note.id)
params.delete('peekNote')
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false }) router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null) setPeekState(null)
}, [noteId, peekState, router, searchParams]) }, [noteId, peekState, router, searchParams])
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
blockId={peekState.blockId} blockId={peekState.blockId}
onClose={handleClosePeek} onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully} onOpenFully={handleOpenPeekFully}
onBackToDashboard={
searchParams.get('from') === 'dashboard'
? () => router.replace('/home')
: undefined
}
/> />
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { X, Maximize2 } from 'lucide-react' import { X, Maximize2, LayoutGrid } from 'lucide-react'
import type { Note } from '@/lib/types' import type { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context' import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
@@ -17,6 +17,7 @@ interface NoteEditorSplitPeekProps {
blockId?: string blockId?: string
onClose: () => void onClose: () => void
onOpenFully: () => void onOpenFully: () => void
onBackToDashboard?: () => void
} }
function PeekEditorBody({ blockId }: { blockId?: string }) { function PeekEditorBody({ blockId }: { blockId?: string }) {
@@ -54,7 +55,7 @@ function PeekEditorBody({ blockId }: { blockId?: string }) {
) )
} }
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) { export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully, onBackToDashboard }: NoteEditorSplitPeekProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar' const isRtl = language === 'fa' || language === 'ar'
@@ -77,6 +78,16 @@ export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: Not
{t('notePeek.label')} {t('notePeek.label')}
</span> </span>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{onBackToDashboard && (
<button
type="button"
onClick={onBackToDashboard}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<LayoutGrid size={12} />
{t('notes.backToDashboard')}
</button>
)}
<button <button
type="button" type="button"
onClick={onOpenFully} onClick={onOpenFully}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useRef, useCallback, useEffect } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useNoteEditorContext } from './note-editor-context' import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager' import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge' import { LabelBadge } from '@/components/label-badge'
@@ -19,11 +20,14 @@ import { Badge } from '@/components/ui/badge'
import { import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History, Clapperboard
} from 'lucide-react' } from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog' import { NoteShareDialog } from './note-share-dialog'
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes' import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import { emitNoteChange } from '@/lib/note-change-sync' import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types' import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
@@ -47,18 +51,23 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) { export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext() const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage() const { t, language } = useLanguage()
const searchParams = useSearchParams()
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
const { requestAiConsent } = useAiConsent() const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false) const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false) const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false) const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [publishOpen, setPublishOpen] = useState(false) const [publishOpen, setPublishOpen] = useState(false)
const [publishLoading, setPublishLoading] = useState(false) const [publishLoading, setPublishLoading] = useState(false)
const [interactivePageOpen, setInteractivePageOpen] = useState(false)
const [interactivePageContent, setInteractivePageContent] = useState('')
const [publishMeta, setPublishMeta] = useState({ const [publishMeta, setPublishMeta] = useState({
isPublic: Boolean(note.isPublic), isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null, slug: note.publicSlug ?? null,
template: (note.publishedTemplate as PublishTemplateId | null) ?? null, template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
}) })
const [publishLinkCopied, setPublishLinkCopied] = useState(false) const [publishLinkCopied, setPublishLinkCopied] = useState(false)
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine') const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
const [publishRewrite, setPublishRewrite] = useState(false) const [publishRewrite, setPublishRewrite] = useState(false)
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null) const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
@@ -343,6 +352,33 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
magazine: t('richTextEditor.publishTemplateMagazine'), magazine: t('richTextEditor.publishTemplateMagazine'),
brief: t('richTextEditor.publishTemplateBrief'), brief: t('richTextEditor.publishTemplateBrief'),
essay: t('richTextEditor.publishTemplateEssay'), essay: t('richTextEditor.publishTemplateEssay'),
'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
}
const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
(tpl) => tpl !== 'interactive-page'
)
const handlePublishInteractivePage = async () => {
if (publishLoading) return
setPublishLoading(true)
try {
const consented = await requestAiConsent()
if (!consented) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
const html =
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
state.content ||
note.content ||
''
setInteractivePageContent(html)
setPublishOpen(false)
setInteractivePageOpen(true)
} finally {
setPublishLoading(false)
}
} }
const handlePublishWithAi = async () => { const handlePublishWithAi = async () => {
@@ -539,7 +575,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity" className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
<span className="text-sm font-medium">{t('notes.backToCollection')}</span> <span className="text-sm font-medium">
{fromDashboard ? t('notes.backToDashboard') : t('notes.backToCollection')}
</span>
</button> </button>
<div className="flex items-center gap-1.5 sm:gap-2"> <div className="flex items-center gap-1.5 sm:gap-2">
@@ -664,6 +702,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.publishSimpleHint')} {t('richTextEditor.publishSimpleHint')}
</p> </p>
<button
type="button"
onClick={handlePublishInteractivePage}
disabled={publishLoading}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
>
{publishLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Clapperboard size={14} />
)}
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</button>
<p className="text-[10px] text-muted-foreground text-center -mt-1">
{t('richTextEditor.publishInteractivePageHint') ||
'Hero, sections, démos Play/Step — 20 crédits'}
</p>
<div className="border-t border-border/50 pt-3 space-y-2"> <div className="border-t border-border/50 pt-3 space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Sparkles size={13} className="text-brand-accent shrink-0" /> <Sparkles size={13} className="text-brand-accent shrink-0" />
@@ -679,7 +736,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</p> </p>
{/* Sélection template */} {/* Sélection template */}
<div className="space-y-1"> <div className="space-y-1">
{PUBLISH_TEMPLATES.map((tpl) => ( {classicPublishTemplates.map((tpl) => (
<label <label
key={tpl} key={tpl}
className={cn( className={cn(
@@ -833,12 +890,12 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }} onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left" className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
> >
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400"> <div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
<GraduationCap size={16} /> <GraduationCap size={16} />
</div> </div>
<div> <div>
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div> <div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div> <div className="text-sm text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Elles reviennent au bon moment'}</div>
</div> </div>
</button> </button>
<button <button
@@ -965,14 +1022,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={() => setConfirmDeleteOpen(true)}
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.noteDeletedToast'))
onClose()
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600" className="text-red-600 dark:text-red-400 focus:text-red-600"
> >
<Trash2 className="h-4 w-4 me-2" /> <Trash2 className="h-4 w-4 me-2" />
@@ -982,6 +1032,19 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenu> </DropdownMenu>
)} )}
<ConfirmDeleteNoteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
onConfirm={async () => {
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
showNoteTrashedToast(note, t)
onClose()
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
}}
/>
{shareOpen && ( {shareOpen && (
<NoteShareDialog <NoteShareDialog
noteId={note.id} noteId={note.id}
@@ -1009,6 +1072,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}} }}
/> />
<InteractivePagePublishDialog
open={interactivePageOpen}
onOpenChange={setInteractivePageOpen}
noteId={note.id}
content={interactivePageContent}
language={language}
onPublished={(slug) => {
setPublishMeta({
isPublic: true,
slug,
template: 'interactive-page',
})
}}
/>
<button <button
aria-label={t('notes.documentInfoAria')} aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }} onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -23,6 +23,8 @@ import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/
import { ReminderDialog } from '@/components/reminder-dialog' import { ReminderDialog } from '@/components/reminder-dialog'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import { fr } from 'date-fns/locale/fr' import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US' import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date' import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
@@ -68,6 +70,7 @@ export function EditorialNoteMenu({
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const [showReminder, setShowReminder] = useState(false) const [showReminder, setShowReminder] = useState(false)
const [movePickerOpen, setMovePickerOpen] = useState(false) const [movePickerOpen, setMovePickerOpen] = useState(false)
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null) const menuTriggerRef = useRef<HTMLButtonElement>(null)
const handleDelete = (e: React.MouseEvent) => { const handleDelete = (e: React.MouseEvent) => {
@@ -76,11 +79,15 @@ export function EditorialNoteMenu({
onDeleteNote(note) onDeleteNote(note)
return return
} }
setConfirmDeleteOpen(true)
}
const confirmFallbackDelete = () => {
startTransition(async () => { startTransition(async () => {
try { try {
await deleteNote(note.id, { skipRevalidation: true }) await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId }) emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée') showNoteTrashedToast(note, t)
} catch { } catch {
toast.error(t('general.error')) toast.error(t('general.error'))
} }
@@ -229,6 +236,12 @@ export function EditorialNoteMenu({
onSave={(date) => patchReminder(date)} onSave={(date) => patchReminder(date)}
onRemove={() => patchReminder(null)} onRemove={() => patchReminder(null)}
/> />
<ConfirmDeleteNoteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
onConfirm={confirmFallbackDelete}
/>
</> </>
) )
} }

View File

@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension' import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension' import { LiveBlockExtension } from './tiptap-live-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension' import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension' import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension' import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension' import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
@@ -60,8 +61,7 @@ import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync' import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { openNotePeek } from '@/lib/note-peek-sync' import { openNotePeek } from '@/lib/note-peek-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider' import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core' import { isTextSelection, type Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
@@ -70,11 +70,11 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2 ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NodeSelection } from '@tiptap/pm/state' import { AllSelection, NodeSelection } from '@tiptap/pm/state'
export interface RichTextEditorHandle { export interface RichTextEditorHandle {
getEditor: () => Editor | null getEditor: () => Editor | null
@@ -298,6 +298,9 @@ const slashCommands: SlashItem[] = [
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database', title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) }, command: (e) => { insertStructuredViewBlockAtSelection(e) },
}, },
{
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
},
{ {
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>', title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
command: (e) => { insertToggleBlock(e) }, command: (e) => { insertToggleBlock(e) },
@@ -385,7 +388,7 @@ function useImageInsert() {
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>( export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) { function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage() const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent() const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert() const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false) const [blockPickerOpen, setBlockPickerOpen] = useState(false)
@@ -589,6 +592,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UndoRedoFeedbackExtension, UndoRedoFeedbackExtension,
LiveBlockExtension, LiveBlockExtension,
StructuredViewBlockExtension, StructuredViewBlockExtension,
InteractiveDemoExtension,
ToggleExtension, ToggleExtension,
CalloutExtension, CalloutExtension,
OutlineExtension, OutlineExtension,
@@ -951,6 +955,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
setChartSuggestionsOpen(true) setChartSuggestionsOpen(true)
}, [editor, requestAiConsent]) }, [editor, requestAiConsent])
const handleGenerateInteractiveDemo = useCallback(async () => {
if (!editor || !editor.isEditable) return
const consented = await requestAiConsent()
if (!consented) return
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
let content = ''
let selection: string | null = null
try {
content = editor.getHTML() || editor.state.doc.textContent || ''
const { from, to, empty } = editor.state.selection
if (!empty) {
const parts: string[] = []
editor.state.doc.nodesBetween(from, to, (node) => {
const latex = (node.attrs as { latex?: string } | undefined)?.latex
if (typeof latex === 'string' && latex.trim()) {
parts.push(
node.type.name === 'mathEquationBlock'
? `$$${latex}$$`
: `$${latex}$`
)
return false
}
if (node.isText && node.text) {
parts.push(node.text)
}
return true
})
selection = parts.join(' ').trim() || null
}
} catch (err) {
console.warn('[interactive-demo] content extract fallback', err)
content = editor.state.doc.textContent || ''
}
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
const words = sourceForWords.split(/\s+/).filter(Boolean).length
if (words < 20) {
toast.error(
t('interactiveDemo.needMoreText') ||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
)
return
}
const toastId = toast.loading(
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
)
try {
const { generateInteractiveDemo } = await import(
'@/lib/ai/services/interactive-demo-client.service'
)
const result = await generateInteractiveDemo({
content,
selection,
lang: language || 'fr',
noteId: noteId || undefined,
})
if (!result.ok) {
if (result.quotaExceeded) {
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
id: toastId,
})
} else {
toast.error(
typeof result.error === 'string'
? result.error
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
return
}
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
toast.error(
t('interactiveDemo.insertFailed') || 'Impossible dinsérer la démo',
{ id: toastId }
)
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
toast.success(
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
{ id: toastId }
)
} catch (err) {
console.error('[interactive-demo] generate error', err)
toast.error(
t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
}, [editor, requestAiConsent, t, language, noteId])
const insertCitationInEditor = useCallback(( const insertCitationInEditor = useCallback((
payload: { noteId: string; noteTitle: string; excerpt: string }, payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean } options?: { atEnd?: boolean }
@@ -1304,24 +1402,39 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<BubbleMenu <BubbleMenu
editor={editor} editor={editor}
className="notion-bubble-menu" className="notion-bubble-menu"
{...({ appendTo={() => document.body}
tippyOptions: { shouldShow={({ editor: e, state, from, to, view, element }) => {
appendTo: () => document.body, if (!e.isEditable || e.isActive('codeBlock')) return false
zIndex: 99999,
fallbackPlacements: ['bottom', 'top'] const { selection } = state
if (selection instanceof AllSelection) return false
if (selection instanceof NodeSelection) {
return selection.node.type.name === 'image'
} }
} as any)} if (!isTextSelection(selection) || selection.empty) return false
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection const selectedText = state.doc.textBetween(from, to)
const isImage = e.isActive('image') if (!selectedText.length) return false
return (from !== to && !e.isActive('codeBlock')) || isImage
// setContent à louverture sélectionne parfois tout le document
const docSize = state.doc.content.size
if (docSize > 4 && from <= 1 && to >= docSize - 1) return false
return view.hasFocus() || element.contains(document.activeElement)
}} }}
> >
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} /> <BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
</BubbleMenu> </BubbleMenu>
)} )}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />} {editor && (
<SlashCommandMenu
editor={editor}
onInsertImage={imageInsert.requestInsert}
onSuggestCharts={handleOpenChartSuggestions}
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
/>
)}
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} /> <EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
@@ -1910,7 +2023,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
} }
} }
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) { function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
const { t } = useLanguage() const { t } = useLanguage()
const { requestAiConsent } = useAiConsent() const { requestAiConsent } = useAiConsent()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -1965,9 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' }, { ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' }, { ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' }, { ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' }, { ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Proposer des graphiques', description: t('richTextEditor.slashChartsDesc') || 'Suggérer des graphiques daprès la note', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' }, { ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc lié', description: t('richTextEditor.slashLivingBlockDesc') || 'Ouvrir un passage dune autre note à côté', categoryId: 'embed' },
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] }, { ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Parcours pédagogique dans la note', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] }, { ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] }, { ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] }, { ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
@@ -2080,9 +2194,14 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
finally { setAiLoading(false) } finally { setAiLoading(false) }
} else if ( } else if (
item.title === 'Suggest Charts' item.title === 'Suggest Charts'
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA') || item.title === (t('richTextEditor.slashCharts') || 'Proposer des graphiques')
) { ) {
deleteSlashText(); closeMenu(); onSuggestCharts() deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (
item.title === 'Interactive Demo'
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas')
) {
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
} else if (item.title === t('richTextEditor.slashDatabase')) { } else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu() deleteSlashText(); closeMenu()
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
@@ -2092,7 +2211,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else { } else {
deleteSlashText(); item.command(editor); closeMenu() deleteSlashText(); item.command(editor); closeMenu()
} }
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent]) }, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
// Charger les favoris fréquents lors de l'ouverture // Charger les favoris fréquents lors de l'ouverture
useEffect(() => { useEffect(() => {
@@ -2278,7 +2397,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const selectedItem = filtered[selectedIndex] const selectedItem = filtered[selectedIndex]
const showPreview = selectedItem && [ const showPreview = selectedItem && [
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code' 'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Bloc lié', 'Linked block', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
].includes(selectedItem.title) ].includes(selectedItem.title)
return createPortal( return createPortal(

View File

@@ -78,7 +78,9 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
// Load saved queries from localStorage // Load saved queries from localStorage
useEffect(() => { useEffect(() => {
try { try {
const stored = localStorage.getItem('momento-search-saved') const stored =
localStorage.getItem('memento-search-saved')
?? localStorage.getItem('momento-search-saved')
if (stored) setSavedQueries(JSON.parse(stored)) if (stored) setSavedQueries(JSON.parse(stored))
} catch {} } catch {}
}, []) }, [])
@@ -405,14 +407,20 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
if (!query.trim()) return if (!query.trim()) return
setSavedQueries(prev => { setSavedQueries(prev => {
const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()] const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()]
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {} try {
localStorage.setItem('memento-search-saved', JSON.stringify(next))
localStorage.removeItem('momento-search-saved')
} catch {}
return next return next
}) })
} }
const handleRemoveQuery = () => { const handleRemoveQuery = () => {
setSavedQueries(prev => { setSavedQueries(prev => {
const next = prev.filter(q => q !== query.trim()) const next = prev.filter(q => q !== query.trim())
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {} try {
localStorage.setItem('memento-search-saved', JSON.stringify(next))
localStorage.removeItem('momento-search-saved')
} catch {}
return next return next
}) })
} }
@@ -671,7 +679,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
router.push(`/home?openNote=${activeMatch.noteId}`) router.push(`/home?openNote=${activeMatch.noteId}`)
onClose() onClose()
}} }}
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm" className="px-5 py-2.5 bg-brand-accent text-white hover:bg-brand-accent/90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
> >
<CornerDownRight size={13} /> <CornerDownRight size={13} />
<span>{t('searchModal.openInEditor')}</span> <span>{t('searchModal.openInEditor')}</span>

View File

@@ -35,7 +35,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
<Link <Link
key={tab.id} key={tab.id}
href={tab.href} href={tab.href}
className="flex items-center gap-1.5 sm:gap-2.5 px-2 sm:px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60" className="flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-2.5 text-[13px] font-medium transition-all relative whitespace-nowrap text-concrete hover:text-ink"
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }} style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
> >
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span> <span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'; import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
@@ -10,6 +10,7 @@ import { toast } from 'sonner';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { BillingHistory } from './billing-history'; import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
type Tier = 'PRO' | 'BUSINESS'; type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year'; type Interval = 'month' | 'year';
@@ -22,6 +23,9 @@ interface BillingStatus {
currentPeriodEnd: string | null; currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean; hasStripeSubscription: boolean;
trialEndsAt?: string | null;
trialEligible?: boolean;
trialDays?: number;
billingEnabled?: boolean; billingEnabled?: boolean;
prices?: { prices?: {
PRO: { PRO: {
@@ -55,7 +59,7 @@ function getStripePromise(enabled: boolean) {
} }
export function BillingPlans() { export function BillingPlans() {
const { t } = useLanguage(); const { t, language } = useLanguage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [interval, setInterval] = useState<Interval>('month'); const [interval, setInterval] = useState<Interval>('month');
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null); const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
@@ -65,6 +69,7 @@ export function BillingPlans() {
const [portalLoading, setPortalLoading] = useState(false); const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null); const [successBanner, setSuccessBanner] = useState<string | null>(null);
const plansSectionRef = useRef<HTMLDivElement>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({ const { data: status, isLoading } = useQuery<BillingStatus>({
queryKey: ['billing', 'status'], queryKey: ['billing', 'status'],
@@ -76,6 +81,17 @@ export function BillingPlans() {
}, },
}); });
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 { data: usageData } = useQuery({ const { data: usageData } = useQuery({
queryKey: ['usage', 'current'], queryKey: ['usage', 'current'],
queryFn: async () => { queryFn: async () => {
@@ -231,6 +247,18 @@ export function BillingPlans() {
} }
}; };
const scrollToPlans = () => {
plansSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleChangePlan = (tier: Tier) => {
if (status?.hasStripeSubscription) {
void handlePortal('portal');
return;
}
void handleCheckout(tier);
};
const handleCheckoutComplete = useCallback(() => { const handleCheckoutComplete = useCallback(() => {
setIsCheckoutOpen(false); setIsCheckoutOpen(false);
setCheckoutClientSecret(null); setCheckoutClientSecret(null);
@@ -250,6 +278,12 @@ export function BillingPlans() {
const effectiveTier = status?.effectiveTier ?? 'BASIC'; const effectiveTier = status?.effectiveTier ?? 'BASIC';
const isPaid = effectiveTier !== 'BASIC'; const isPaid = effectiveTier !== 'BASIC';
const isTrialing = (status?.status ?? '').toUpperCase() === 'TRIALING';
const trialEligible = !!status?.trialEligible;
const trialDays = status?.trialDays ?? SUBSCRIPTION_TRIAL_DAYS;
const trialCta = (fallback: string) =>
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
const plans = [ const plans = [
{ {
@@ -266,11 +300,15 @@ export function BillingPlans() {
t('billing.freeF5'), t('billing.freeF5'),
], ],
current: effectiveTier === 'BASIC', current: effectiveTier === 'BASIC',
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'), buttonText: effectiveTier === 'BASIC'
? (t('billing.currentPlan') || 'Plan Actuel')
: t('billing.downgradeToFree'),
buttonClass: effectiveTier === 'BASIC' buttonClass: effectiveTier === 'BASIC'
? 'bg-paper text-concrete cursor-default' ? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95', : 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => {}, onClick: () => {
if (effectiveTier !== 'BASIC') void handleCancelSubscription();
},
}, },
{ {
id: 'pro', id: 'pro',
@@ -280,20 +318,21 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'), period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.', description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [ features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'), t('billing.proFeature1'),
t('billing.proFeature2'), t('billing.proFeature2'),
t('billing.proFeature3'), t('billing.proFeature3', { count: providerCount }),
t('billing.proFeature4'), t('billing.proFeature4'),
t('billing.proFeature5'), t('billing.proFeature5'),
t('billing.proFeature6'), t('billing.proFeature6'),
], ],
current: effectiveTier === 'PRO', current: effectiveTier === 'PRO',
popular: true, popular: true,
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'), buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.proCta') || 'Passer au Plan Pro'),
buttonClass: effectiveTier === 'PRO' buttonClass: effectiveTier === 'PRO'
? 'bg-paper text-concrete cursor-default' ? 'bg-paper text-concrete cursor-default'
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95', : 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => handleCheckout('PRO'), onClick: () => handleChangePlan('PRO'),
}, },
{ {
id: 'business', id: 'business',
@@ -302,19 +341,20 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')), (interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'), period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [ features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'), t('billing.businessFeature1'),
t('billing.businessFeature2'), t('billing.businessFeature2'),
t('billing.businessFeature3'), t('billing.businessFeature3', { count: providerCount }),
t('billing.businessFeature4'), t('billing.businessFeature4'),
t('billing.businessFeature5'), t('billing.businessFeature5'),
t('billing.businessFeature6'), t('billing.businessFeature6'),
], ],
current: effectiveTier === 'BUSINESS', current: effectiveTier === 'BUSINESS',
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'), buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
buttonClass: effectiveTier === 'BUSINESS' buttonClass: effectiveTier === 'BUSINESS'
? 'bg-paper text-concrete cursor-default' ? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95', : 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => handleCheckout('BUSINESS'), onClick: () => handleChangePlan('BUSINESS'),
}, },
{ {
id: 'enterprise', id: 'enterprise',
@@ -333,22 +373,24 @@ export function BillingPlans() {
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'), buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
buttonClass: effectiveTier === 'ENTERPRISE' buttonClass: effectiveTier === 'ENTERPRISE'
? 'bg-paper text-concrete cursor-default' ? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95', : 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; }, onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
}, },
]; ];
const plansToShow = isPaid ? plans.filter((p) => p.id !== 'free') : plans; const plansToShow = plans;
const formatDate = (dateStr: string | null | undefined) => { const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '—'; if (!dateStr) return '—';
try { try {
const date = new Date(dateStr); const date = new Date(dateStr);
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR'; const locale = language === 'fa' ? 'fa-IR' : language === 'zh' ? 'zh-CN' : language;
return new Intl.DateTimeFormat(locale, { return new Intl.DateTimeFormat(locale, {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
timeZone: 'UTC',
...(language === 'fa' ? { calendar: 'persian' as const } : {}),
}).format(date); }).format(date);
} catch (e) { } catch (e) {
return dateStr; return dateStr;
@@ -415,9 +457,11 @@ export function BillingPlans() {
<div className="ml-auto"> <div className="ml-auto">
<span className={cn( <span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest', 'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE' isTrialing
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20' ? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20' : status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}> )}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')} {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span> </span>
@@ -425,6 +469,12 @@ export function BillingPlans() {
)} )}
</div> </div>
{isTrialing && status?.trialEndsAt && (
<p className="text-xs text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/20 rounded-xl px-3 py-2">
{t('billing.trialEndsOn', { date: formatDate(status.trialEndsAt) })}
</p>
)}
{isPaid && ( {isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1"> <div className="space-y-1">
@@ -439,39 +489,61 @@ export function BillingPlans() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider"> <span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')} {isTrialing
? t('billing.trialEndsLabel')
: status?.cancelAtPeriodEnd
? t('billing.expiresOn')
: t('billing.nextBillingDate')}
</span> </span>
<p className="text-xs font-semibold text-ink"> <p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'} {isTrialing && status?.trialEndsAt
? formatDate(status.trialEndsAt)
: status?.currentPeriodEnd
? formatDate(status.currentPeriodEnd)
: '—'}
</p> </p>
</div> </div>
</div> </div>
)} )}
{isPaid && ( <div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-3"> <button
type="button"
onClick={scrollToPlans}
className="flex items-center gap-2 px-5 py-2.5 bg-brand-accent text-white rounded-xl text-xs font-semibold hover:opacity-90 transition-all shadow-md shadow-brand-accent/20"
>
{t('billing.changeOffer')}
</button>
{isPaid && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription')}
</button>
)}
{isPaid && status?.hasStripeSubscription && (
<button <button
type="button" type="button"
onClick={handlePortal} onClick={handlePortal}
disabled={portalLoading} disabled={portalLoading}
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black rounded-xl text-xs font-semibold hover:opacity-90 disabled:opacity-60 transition-all shadow-md shadow-black/5" className="flex items-center gap-2 px-5 py-2.5 border border-border text-ink rounded-xl text-xs font-semibold hover:bg-paper/60 dark:hover:bg-white/5 disabled:opacity-60 transition-all"
> >
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />} {portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
{t('billing.manageBilling') || 'Gérer la facturation'} {t('billing.manageBilling')}
</button> </button>
)}
</div>
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && ( {isPaid && status?.cancelAtPeriodEnd && (
<button <p className="text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2">
type="button" {t('billing.cancellingNotice', { date: formatDate(status.currentPeriodEnd) })}
onClick={handleCancelSubscription} </p>
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
</button>
)}
</div>
)} )}
</div> </div>
@@ -572,7 +644,7 @@ export function BillingPlans() {
type="button" type="button"
onClick={() => handleBuyPack(pack.id)} onClick={() => handleBuyPack(pack.id)}
disabled={!pack.configured || packLoading !== null} disabled={!pack.configured || packLoading !== null}
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all" className="mt-auto w-full py-3 rounded-2xl bg-brand-accent text-white text-[13px] font-semibold uppercase tracking-wider hover:opacity-90 disabled:opacity-40 transition-all"
> >
{packLoading === pack.id ? ( {packLoading === pack.id ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" /> <Loader2 className="h-4 w-4 animate-spin mx-auto" />
@@ -619,6 +691,7 @@ export function BillingPlans() {
brainstorm_expand: t('usageMeter.featureBrainstormExpand'), brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'), brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'), suggest_charts: t('usageMeter.featureCharts'),
interactive_demo: t('usageMeter.featureInteractiveDemo'),
publish_enhance: t('usageMeter.featurePublishEnhance'), publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'), ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'), voice_transcribe: t('usageMeter.featureVoice'),
@@ -632,8 +705,8 @@ export function BillingPlans() {
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0 const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
const barFillColor = const barFillColor =
pct >= 40 pct >= 40
? 'bg-gradient-to-r from-violet-400 to-purple-400' ? 'bg-brand-accent'
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80' : 'bg-brand-accent/60'
return ( return (
<div <div
key={key} key={key}
@@ -664,8 +737,7 @@ export function BillingPlans() {
{isPaid && <BillingHistory />} {isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */} {/* Interval Toggle & Plan Cards */}
{!isPaid && ( <div ref={plansSectionRef} className="space-y-8 pt-6 border-t border-border/40">
<div className="space-y-8 pt-6 border-t border-border/40">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete"> <h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.upgradePlan') || 'Changer de plan'} {t('billing.upgradePlan') || 'Changer de plan'}
@@ -679,7 +751,7 @@ export function BillingPlans() {
onClick={() => setInterval('month')} onClick={() => setInterval('month')}
className={cn( className={cn(
'px-4 py-1.5 text-xs font-medium rounded-full transition-all', 'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
interval === 'month' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink' interval === 'month' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
)} )}
> >
{t('billing.monthly')} {t('billing.monthly')}
@@ -689,7 +761,7 @@ export function BillingPlans() {
onClick={() => setInterval('year')} onClick={() => setInterval('year')}
className={cn( className={cn(
'px-4 py-1.5 text-xs font-medium rounded-full transition-all', 'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
interval === 'year' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink' interval === 'year' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
)} )}
> >
{t('billing.annual')} {t('billing.annual')}
@@ -744,7 +816,12 @@ export function BillingPlans() {
<button <button
onClick={plan.onClick} onClick={plan.onClick}
disabled={plan.current || checkoutLoading !== null} disabled={
plan.current
|| checkoutLoading !== null
|| cancelLoading
|| (plan.id === 'free' && !!status?.cancelAtPeriodEnd)
}
className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)} className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -755,8 +832,7 @@ export function BillingPlans() {
</div> </div>
))} ))}
</div> </div>
</div> </div>
)}
{/* Footer Info */} {/* Footer Info */}
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8"> <div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">

View File

@@ -0,0 +1,31 @@
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
const TITLE_KEYS: Record<string, string> = {
'/settings/general': 'generalSettings.title',
'/settings/ai': 'aiSettings.title',
'/settings/billing': 'billing.title',
'/settings/appearance': 'appearance.title',
'/settings/profile': 'profile.title',
'/settings/data': 'dataManagement.title',
'/settings/published': 'settings.publishedTitle',
'/settings/integrations': 'integrations.title',
'/settings/mcp': 'mcpSettings.title',
'/settings/about': 'about.title',
}
export function SettingsDocumentTitle() {
const pathname = usePathname()
const { t } = useLanguage()
useEffect(() => {
const key = Object.keys(TITLE_KEYS).find((href) => pathname === href || pathname.startsWith(`${href}/`))
const section = key ? t(TITLE_KEYS[key]) : t('settings.title')
document.title = `${section} — Memento`
}, [pathname, t])
return null
}

View File

@@ -28,7 +28,7 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-border/10 transition-colors" className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-border/10 transition-colors"
> >
<HelpCircle size={14} className="text-brand-accent shrink-0" /> <HelpCircle size={14} className="text-brand-accent shrink-0" />
<span className="text-[12px] font-semibold text-ink flex-1">{title}</span> <span className="text-sm font-semibold text-ink flex-1">{title}</span>
{open ? ( {open ? (
<ChevronUp size={13} className="text-concrete shrink-0" /> <ChevronUp size={13} className="text-concrete shrink-0" />
) : ( ) : (
@@ -40,10 +40,10 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
<ol className="px-4 pb-4 space-y-2.5 border-t border-border/30 pt-3"> <ol className="px-4 pb-4 space-y-2.5 border-t border-border/30 pt-3">
{steps.map((step, i) => ( {steps.map((step, i) => (
<li key={i} className="flex items-start gap-2.5"> <li key={i} className="flex items-start gap-2.5">
<span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5"> <span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-xs font-semibold flex items-center justify-center shrink-0 mt-0.5">
{step.icon ?? i + 1} {step.icon ?? i + 1}
</span> </span>
<span className="text-[12px] text-concrete leading-relaxed"> <span className="text-sm text-concrete leading-relaxed">
{step.text} {step.text}
{step.link && ( {step.link && (
<> <>

View File

@@ -32,6 +32,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
brainstorm_expand: 'usageMeter.featureBrainstormExpand', brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich', brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts', suggest_charts: 'usageMeter.featureCharts',
interactive_demo: 'usageMeter.featureInteractiveDemo',
interactive_page: 'usageMeter.featureInteractivePage',
publish_enhance: 'usageMeter.featurePublishEnhance', publish_enhance: 'usageMeter.featurePublishEnhance',
ai_flashcard: 'usageMeter.featureFlashcards', ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice', voice_transcribe: 'usageMeter.featureVoice',

View File

@@ -36,6 +36,13 @@ import {
Folder, Folder,
FolderOpen, FolderOpen,
LayoutGrid, LayoutGrid,
Palette,
CreditCard,
Database,
Globe,
Plug,
Key,
Info,
} from 'lucide-react' } from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context' import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
@@ -67,7 +74,7 @@ import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route' import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm' import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights' type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights' | 'settings'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height' const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
@@ -859,6 +866,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
}, [currentNotebookId, notebooks]) }, [currentNotebookId, notebooks])
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams) const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const panelView: NavigationView = pathname.startsWith('/settings') ? 'settings' : activeView
const isInboxActive = const isInboxActive =
pathname === '/home' && pathname === '/home' &&
@@ -873,6 +881,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents') else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
else if (pathname === '/insights') setActiveView('insights') else if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision') else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (pathname.startsWith('/settings')) setActiveView('settings')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders') else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (isDashboardRoute) setActiveView('dashboard') else if (isDashboardRoute) setActiveView('dashboard')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks') else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
@@ -1386,8 +1395,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
)} )}
aria-hidden={isImmersiveRoute && userCollapsed ? true : undefined} aria-hidden={isImmersiveRoute && userCollapsed ? true : undefined}
> >
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */} {/* ── Column 1 : Rail icône + libellé — destinations lisibles sans survol ── */}
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none overflow-hidden"> <div className="w-[72px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-3 shrink-0 select-none overflow-y-auto overflow-x-hidden custom-scrollbar">
{/* Top : Logo + navigation */} {/* Top : Logo + navigation */}
<div className="flex flex-col items-center gap-[18px] w-full"> <div className="flex flex-col items-center gap-[18px] w-full">
@@ -1425,15 +1434,15 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</DropdownMenu> </DropdownMenu>
{/* Boutons de navigation principaux */} {/* Boutons de navigation principaux */}
<div className="flex flex-col gap-2 w-full px-1.5"> <div className="flex flex-col gap-1 w-full px-1">
{([ {([
{ {
id: 'dashboard', id: 'dashboard',
icon: LayoutGrid, icon: LayoutGrid,
label: t('nav.dashboard') || 'Dashboard', label: t('nav.home'),
onClick: () => { onClick: () => {
setActiveView('dashboard') setActiveView('dashboard')
router.push('/home') router.replace('/home')
}, },
isActive: isDashboardRoute, isActive: isDashboardRoute,
}, },
@@ -1470,7 +1479,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{ {
id: 'agents', id: 'agents',
icon: Bot, icon: Bot,
label: t('agents.intelligenceOS') || 'Intelligence IA', label: t('nav.agents'),
onClick: () => { onClick: () => {
setActiveView('agents') setActiveView('agents')
router.push('/agents') router.push('/agents')
@@ -1492,20 +1501,17 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
aria-current={item.isActive ? 'page' : undefined} aria-current={item.isActive ? 'page' : undefined}
onClick={item.onClick} onClick={item.onClick}
className={cn( className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group', 'w-full rounded-lg flex flex-col items-center justify-center gap-0.5 py-1.5 px-0.5 transition-all relative',
item.isActive item.isActive
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25' ? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]' : 'text-ink/70 hover:text-ink dark:text-white/70 dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)} )}
> >
{item.isActive && ( {item.isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-brand-accent rounded-r-full" /> <div className="absolute start-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-brand-accent rounded-e-full" />
)} )}
<item.icon size={16} /> <item.icon size={16} aria-hidden />
<span <span className="text-[11px] leading-tight font-semibold text-center max-w-full line-clamp-2">
aria-hidden="true"
className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider"
>
{item.label} {item.label}
</span> </span>
</button> </button>
@@ -1519,6 +1525,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link <Link
href="/trash" href="/trash"
aria-label={t('sidebar.trash')}
className={cn( className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group', 'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/trash' pathname === '/trash'
@@ -1538,6 +1545,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link <Link
href="/archive" href="/archive"
aria-label={t('sidebar.archive')}
className={cn( className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group', 'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/archive' pathname === '/archive'
@@ -1554,6 +1562,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link <Link
href="/home?shared=1&forceList=1" href="/home?shared=1&forceList=1"
aria-label={t('sidebar.sharedWithMe')}
className={cn( className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group', 'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
searchParams.get('shared') === '1' && pathname === '/home' searchParams.get('shared') === '1' && pathname === '/home'
@@ -1570,7 +1579,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button <button
onClick={openSearch} onClick={openSearch}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group" className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
title="Ctrl+K" aria-label={t('sidebar.searchShortcut')}
title={t('sidebar.searchShortcut')}
> >
<Search size={15} /> <Search size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider"> <span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
@@ -1580,6 +1590,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button <button
onClick={toggleTheme} onClick={toggleTheme}
aria-label={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group" className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
> >
{isDark ? <Sun size={15} /> : <Moon size={15} />} {isDark ? <Sun size={15} /> : <Moon size={15} />}
@@ -1590,6 +1601,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link <Link
href="/settings" href="/settings"
aria-label={t('nav.settings')}
onClick={() => setActiveView('settings')}
className={cn( className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group', 'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname.startsWith('/settings') pathname.startsWith('/settings')
@@ -1606,6 +1619,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button <button
onClick={() => performSignOut('/login')} onClick={() => performSignOut('/login')}
aria-label={t('sidebar.signOut')}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group" className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group"
> >
<LogOut size={14} /> <LogOut size={14} />
@@ -1623,14 +1637,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div <div
className={cn( className={cn(
'flex-1 flex flex-col min-h-0 -mx-0 pb-4', 'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
activeView === 'notebooks' panelView === 'notebooks'
? 'overflow-hidden' ? 'overflow-hidden'
: 'overflow-y-auto custom-scrollbar space-y-6', : 'overflow-y-auto custom-scrollbar space-y-6',
)} )}
> >
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{activeView === 'dashboard' ? ( {panelView === 'dashboard' ? (
<motion.div <motion.div
key="dashboard" key="dashboard"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }} initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1649,45 +1663,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{t('sidebar.dashboardPanelBody')} {t('sidebar.dashboardPanelBody')}
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
<button
type="button"
onClick={handleInboxClick}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<span className="flex items-center gap-2 min-w-0">
<Inbox size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.inbox')}
</span>
{inboxCount > 0 && (
<span className="text-[10px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
{inboxCount}
</span>
)}
</button>
<button
type="button"
onClick={() => router.push('/revision')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<GraduationCap size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.review')}
</button>
<button
type="button"
onClick={handleRemindersClick}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Bell size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.reminders')}
</button>
<button
type="button"
onClick={() => router.push('/insights')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Sparkles size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.themes')}
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1701,7 +1676,54 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</button> </button>
</div> </div>
</motion.div> </motion.div>
) : activeView === 'notebooks' ? ( ) : panelView === 'settings' ? (
<motion.div
key="settings"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4"
>
<div className="flex items-center gap-1.5 mb-6">
<Settings size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('settings.title')}
</h3>
</div>
<div className="space-y-1">
{[
{ id: 'general', href: '/settings/general', label: t('generalSettings.title'), icon: Settings },
{ id: 'ai', href: '/settings/ai', label: t('aiSettings.title'), icon: Sparkles },
{ id: 'billing', href: '/settings/billing', label: t('billing.title'), icon: CreditCard },
{ id: 'appearance', href: '/settings/appearance', label: t('appearance.title'), icon: Palette },
{ id: 'profile', href: '/settings/profile', label: t('profile.title'), icon: User },
{ id: 'data', href: '/settings/data', label: t('dataManagement.title'), icon: Database },
{ id: 'published', href: '/settings/published', label: t('settings.publishedTitle') || 'Mes pages', icon: Globe },
{ id: 'integrations', href: '/settings/integrations', label: t('integrations.title') || 'Intégrations', icon: Plug },
{ id: 'mcp', href: '/settings/mcp', label: t('mcpSettings.title'), icon: Key },
{ id: 'about', href: '/settings/about', label: t('about.title'), icon: Info },
].map((tab) => {
const isActive = pathname === tab.href || pathname.startsWith(`${tab.href}/`)
return (
<Link
key={tab.id}
href={tab.href}
className={cn(
'w-full text-start px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5',
isActive
? 'text-ink bg-brand-accent/10'
: 'text-muted-foreground hover:text-ink hover:bg-black/5 dark:hover:bg-white/5',
)}
>
<tab.icon size={12} className="text-concrete shrink-0" />
<span className="font-semibold">{tab.label}</span>
</Link>
)
})}
</div>
</motion.div>
) : panelView === 'notebooks' ? (
<motion.div <motion.div
key="notebooks" key="notebooks"
ref={notebooksContainerRef} ref={notebooksContainerRef}
@@ -1717,7 +1739,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<BookMarked size={14} className="text-brand-accent" /> <BookMarked size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink"> <h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('sidebar.documents')} {t('nav.notebooks')}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
@@ -1889,7 +1911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div> </div>
</div> </div>
</motion.div> </motion.div>
) : activeView === 'insights' ? ( ) : panelView === 'insights' ? (
<motion.div <motion.div
key="insights" key="insights"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }} initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1911,12 +1933,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button" type="button"
onClick={() => router.push('/home')} onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground" className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Home size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToHome')}
</button>
<button
type="button"
onClick={() => router.push('/home?forceList=1')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
> >
<BookOpen size={14} className="text-brand-accent shrink-0" /> <BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')} {t('sidebar.backToNotebooks')}
</button> </button>
</motion.div> </motion.div>
) : activeView === 'revision' ? ( ) : panelView === 'revision' ? (
<motion.div <motion.div
key="revision" key="revision"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }} initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1938,12 +1968,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button" type="button"
onClick={() => router.push('/home')} onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground" className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Home size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToHome')}
</button>
<button
type="button"
onClick={() => router.push('/home?forceList=1')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
> >
<BookOpen size={14} className="text-brand-accent shrink-0" /> <BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')} {t('sidebar.backToNotebooks')}
</button> </button>
</motion.div> </motion.div>
) : activeView === 'reminders' ? ( ) : panelView === 'reminders' ? (
<motion.div <motion.div
key="reminders" key="reminders"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }} initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1962,7 +2000,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<SidebarReminders onOpenNote={handleReminderNoteClick} /> <SidebarReminders onOpenNote={handleReminderNoteClick} />
</div> </div>
</motion.div> </motion.div>
) : activeView === 'agents' ? ( ) : panelView === 'agents' ? (
<motion.div <motion.div
key="agents" key="agents"
initial={{ opacity: 0, x: isRtl ? -10 : 10 }} initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
@@ -1971,7 +2009,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4"> <p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
{t('agents.intelligenceOS')} {t('nav.agents')}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{[ {[

View File

@@ -0,0 +1,231 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
import { PageMd } from '@/components/interactive-page/page-md'
import type { AnimBeat, SimI18n } from '@/lib/simulators'
import { cn } from '@/lib/utils'
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const SPEEDS = [0.5, 1, 2, 4]
function stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
export type AnimPlayerShellProps = {
beats: AnimBeat[]
lang: string
disclaimer?: SimI18n
/** Scene renderer — receives the current beat index (0-based). */
children: (stepIndex: number) => React.ReactNode
}
/**
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
* + narration panel, same pacing rules as InteractiveDemoPlayer.
*/
export function AnimPlayerShell({
beats,
lang,
disclaimer,
children,
}: AnimPlayerShellProps) {
const fr = lang.startsWith('fr')
const reducedMotion = usePrefersReducedMotion()
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const atLast = stepIndex >= beats.length - 1
useEffect(() => {
clearTimer()
if (!playing) return
const beat = beats[stepIndex]
if (!beat) return
const speak = fr ? beat.speak.fr : beat.speak.en
timerRef.current = setTimeout(
() => {
if (stepIndex >= beats.length - 1) {
setPlaying(false)
return
}
setStepIndex((s) => s + 1)
},
reducedMotion ? 250 : stepDurationMs(speak, speed)
)
return clearTimer
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
// Multi-player pages: only the hovered/focused player answers the keyboard
if (!activeRef.current) return
if (e.code === 'Space') {
e.preventDefault()
setPlaying((p) => !p)
} else if (e.code === 'ArrowRight') {
e.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.max(0, s - 1))
} else if (e.code === 'KeyR') {
e.preventDefault()
setPlaying(false)
setStepIndex(0)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [beats.length])
const beat = beats[stepIndex]
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
return (
<div
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
{children(stepIndex)}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-plum)' }}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
}}
aria-label={fr ? 'Étape suivante' : 'Next step'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<StepForward className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex(0)
}}
aria-label="Reset"
title="R"
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<RotateCcw className="h-4 w-4" />
</button>
<span
className="ml-2 text-xs tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
</span>
<div
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
style={{ borderColor: 'var(--pp-line)' }}
role="group"
aria-label={fr ? 'Vitesse' : 'Speed'}
>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
)}
style={
speed === s
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{s}×
</button>
))}
</div>
</div>
<div
className="mt-2 rounded-lg border px-4 py-3"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
</div>
{disclaimer ? (
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
{fr ? disclaimer.fr : disclaimer.en}
</p>
) : null}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
{beats.map((b) => (
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
))}
</ol>
</noscript>
</div>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Carnot cycle animated scene — piston apparatus + live P-V diagram.
* Driven by AnimPlayerShell via `step` (0..4). Pure SVG, transitions on
* transform/opacity only (GPU-friendly, reduced-motion safe).
*/
const PISTON_Y = [105, 70, 135, 170, 110]
const GAS_HOT = [1, 0.55, 0.12, 0.6, 0.5]
const GAS_COLD = [0, 0.45, 0.9, 0.4, 0.4]
const T_LABEL = ['T_h', 'T_h → T_c', 'T_c', 'T_c → T_h', 'η']
// PV anchors (illustrative)
const A = { x: 405, y: 50 }
const B = { x: 495, y: 159 }
const C = { x: 630, y: 212 }
const D = { x: 465, y: 175 }
const ANCHORS = [A, B, C, D]
const CYCLE = `${A.x},${A.y} ${B.x},${B.y} ${C.x},${C.y} ${D.x},${D.y}`
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
export function CarnotCycleAnimView({ step, lang }: { step: number; lang: string }) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const s = Math.min(step, 4)
const ink = dark ? '#EDE7DB' : '#242422'
const muted = dark ? '#A39C8D' : '#686762'
const plum = dark ? '#D07AA6' : '#9F3F70'
const blue = dark ? '#7FA8CC' : '#3F6F9F'
const line = dark ? '#3B352A' : '#D6D0C6'
const card = dark ? '#221E17' : '#FFFDF8'
const paper = dark ? '#17140F' : '#F4F0E8'
const pistonY = PISTON_Y[s]
const showHotPlate = s === 0
const showColdPlate = s === 2
const showInsulation = s === 1 || s === 3
const showQh = s === 0
const showQc = s === 2
const showWout = s === 0 || s === 1
const showWin = s === 2 || s === 3
const marker = ANCHORS[Math.min(s, 3)]
const segDone = s // segments A→B (0), B→C (1), C→D (2), D→A (3)
return (
<svg
viewBox="0 0 680 300"
className="w-full"
role="img"
aria-label={fr ? 'Animation du cycle de Carnot' : 'Carnot cycle animation'}
style={{ background: paper, display: 'block' }}
>
{/* ══ Piston apparatus ══ */}
<g>
{/* cylinder walls */}
<rect x={70} y={40} width={110} height={195} fill={card} stroke={ink} strokeWidth={2} rx={4} />
{/* gas: cold + hot crossfade */}
<g style={{ transform: `translateY(${pistonY - 45}px)`, transition: EASE }}>
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={blue} opacity={GAS_COLD[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={plum} opacity={GAS_HOT[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
</g>
{/* piston */}
<g style={{ transform: `translateY(${pistonY - 95}px)`, transition: EASE }}>
<rect x={74} y={95} width={102} height={14} fill={ink} rx={3} opacity={0.9} />
<rect x={118} y={60} width={14} height={36} fill={ink} rx={3} opacity={0.9} />
<rect x={105} y={48} width={40} height={12} fill={ink} rx={4} opacity={0.9} />
</g>
{/* hot plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={plum} opacity={showHotPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showHotPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={plum}>
{fr ? 'Source chaude' : 'Hot'} T_h
</text>
) : null}
{/* cold plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={blue} opacity={showColdPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showColdPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={blue}>
{fr ? 'Source froide' : 'Cold'} T_c
</text>
) : null}
{/* insulation */}
<rect x={60} y={240} width={130} height={12} rx={4} fill="none" stroke={muted} strokeWidth={2} strokeDasharray="6 4" opacity={showInsulation ? 1 : 0} style={{ transition: 'opacity 500ms ease' }} />
{showInsulation ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={muted}>
{fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'}
</text>
) : null}
{/* Q_h arrow in */}
<g opacity={showQh ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={95} y1={238} x2={95} y2={196} stroke={plum} strokeWidth={5} strokeLinecap="round" />
<polygon points="95,190 89,202 101,202" fill={plum} />
<text x={70} y={210} fontSize={12} fontWeight={700} fill={plum}>Q_h</text>
</g>
{/* Q_c arrow out */}
<g opacity={showQc ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={155} y1={196} x2={155} y2={238} stroke={blue} strokeWidth={5} strokeLinecap="round" />
<polygon points="155,244 149,232 161,232" fill={blue} />
<text x={164} y={226} fontSize={12} fontWeight={700} fill={blue}>Q_c</text>
</g>
{/* W arrows */}
<g opacity={showWout ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={180} x2={210} y2={120} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,112 204,124 216,124" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
<g opacity={showWin ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={120} x2={210} y2={180} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,188 204,176 216,176" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
{/* temperature label inside gas */}
<text x={125} y={pistonY + 40} textAnchor="middle" fontSize={13} fontWeight={700} fill={ink} style={{ transition: EASE }}>
{T_LABEL[s]}
</text>
</g>
{/* ══ PV diagram ══ */}
<g>
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
PV
</text>
{/* axes */}
<line x1={330} y1={250} x2={660} y2={250} stroke={ink} strokeWidth={1.5} />
<line x1={330} y1={250} x2={330} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={655} y={266} fontSize={11} fill={muted} textAnchor="end">V</text>
<text x={322} y={40} fontSize={11} fill={muted} textAnchor="end">P</text>
{/* isotherms */}
<path d="M 395 56 Q 460 130 660 218" fill="none" stroke={plum} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={200} fontSize={10} fill={plum}>T_h</text>
<path d="M 400 175 Q 520 208 660 230" fill="none" stroke={blue} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={242} fontSize={10} fill={blue}>T_c</text>
{/* cycle area (final beat) */}
<polygon points={CYCLE} fill={plum} opacity={s === 4 ? 0.14 : 0} style={{ transition: 'opacity 600ms ease' }} />
{s === 4 ? (
<text x={505} y={150} fontSize={15} fontWeight={800} fill={plum} textAnchor="middle">W</text>
) : null}
{/* cycle segments, revealed progressively */}
<polyline points={`${A.x},${A.y} ${B.x},${B.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 0 ? 1 : 0.15} />
<polyline points={`${B.x},${B.y} ${C.x},${C.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${C.x},${C.y} ${D.x},${D.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${D.x},${D.y} ${A.x},${A.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* current state marker */}
<circle cx={marker.x} cy={marker.y} r={6} fill={plum} stroke={card} strokeWidth={2} style={{ transition: EASE }} />
</g>
</svg>
)
}

View File

@@ -0,0 +1,672 @@
'use client'
import { useMemo, useState } from 'react'
import {
carnotCycleSimulator as sim,
fridgeLoadFromModeLoad,
modeLoadFromFridgeLoad,
resolveCarnotPhysics,
type CarnotMode,
type CarnotQuantity,
} from '@/lib/simulators/carnot-cycle'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
import { cn } from '@/lib/utils'
const SVG_W = 360
const SVG_H = 270
type TempUnit = 'K' | 'C' | 'F'
const TEMP_UNITS: { id: TempUnit; label: string }[] = [
{ id: 'K', label: 'K' },
{ id: 'C', label: '°C' },
{ id: 'F', label: '°F' },
]
const MODES: { id: CarnotMode; fr: string; en: string }[] = [
{ id: 'fridge', fr: 'Frigo', en: 'Fridge' },
{ id: 'heat_pump', fr: 'PAC', en: 'Heat pump' },
{ id: 'engine', fr: 'Moteur', en: 'Engine' },
]
const QTY: { id: CarnotQuantity; fr: string; en: string }[] = [
{ id: 'energy', fr: 'Énergie (kJ)', en: 'Energy (kJ)' },
{ id: 'power', fr: 'Puissance (W)', en: 'Power (W)' },
]
function kelvinToDisplay(k: number, unit: TempUnit): number {
if (unit === 'C') return k - 273.15
if (unit === 'F') return (k * 9) / 5 - 459.67
return k
}
function displayToKelvin(v: number, unit: TempUnit): number {
if (unit === 'C') return v + 273.15
if (unit === 'F') return ((v + 459.67) * 5) / 9
return v
}
function formatTemp(k: number, unit: TempUnit): string {
const v = kelvinToDisplay(k, unit)
if (unit === 'K') return `${Math.round(v)} K`
if (unit === 'C') {
const r = Math.round(v * 10) / 10
return `${Number.isInteger(r) ? r : r.toFixed(1)} °C`
}
return `${Math.round(v)} °F`
}
function tempSliderMeta(
param: { min: number; max: number; step: number },
unit: TempUnit
): { min: number; max: number; step: number; unitLabel: string } {
if (unit === 'K') {
return { min: param.min, max: param.max, step: param.step, unitLabel: 'K' }
}
if (unit === 'C') {
return {
min: Math.round((param.min - 273.15) * 10) / 10,
max: Math.round((param.max - 273.15) * 10) / 10,
step: 0.5,
unitLabel: '°C',
}
}
return {
min: Math.round(((param.min * 9) / 5 - 459.67) * 10) / 10,
max: Math.round(((param.max * 9) / 5 - 459.67) * 10) / 10,
step: 1,
unitLabel: '°F',
}
}
function formatQty(v: number): string {
if (!Number.isFinite(v)) return '—'
if (Math.abs(v - Math.round(v)) < 0.05) return String(Math.round(v))
return v.toFixed(1)
}
function Segmented<T extends string>({
value,
onChange,
options,
ariaLabel,
}: {
value: T
onChange: (v: T) => void
options: { id: T; label: string }[]
ariaLabel: string
}) {
return (
<div
className="inline-flex max-w-full flex-wrap rounded-lg border border-border/60 bg-background/80 p-0.5"
role="group"
aria-label={ariaLabel}
>
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
className={cn(
'rounded-md px-2 py-1 text-[11px] font-semibold transition-colors',
value === o.id
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={value === o.id}
>
{o.label}
</button>
))}
</div>
)
}
/** Arrowhead + shaft, thickness ∝ |value|/maxRef. */
function FlowArrow({
x1,
y1,
x2,
y2,
value,
maxRef,
color,
label,
unit,
labelSide = 'right',
}: {
x1: number
y1: number
x2: number
y2: number
value: number
maxRef: number
color: string
label: string
unit?: string
labelSide?: 'left' | 'right' | 'above' | 'below'
}) {
const mag = Math.max(0, value)
const t = 2.2 + (mag / Math.max(1, maxRef)) * 10
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const headLen = Math.min(14, Math.max(9, 7 + t * 0.45))
const headHalf = Math.min(7, 3.2 + t * 0.35)
const bx = x2 - ux * headLen
const by = y2 - uy * headLen
const px = -uy
const py = ux
const mx = (x1 + bx) / 2
const my = (y1 + by) / 2
const labelGap = 11 + t * 0.45
let lx = mx
let ly = my
let textAnchor: 'start' | 'middle' | 'end' = 'middle'
if (labelSide === 'above') ly = my - labelGap
else if (labelSide === 'below') ly = my + labelGap
else if (labelSide === 'right') {
lx = mx + labelGap
textAnchor = 'start'
} else {
lx = mx - labelGap
textAnchor = 'end'
}
return (
<g>
<line
x1={x1}
y1={y1}
x2={bx}
y2={by}
stroke={color}
strokeWidth={t}
strokeLinecap="round"
opacity={0.9}
/>
<polygon
points={`${x2},${y2} ${bx + px * headHalf},${by + py * headHalf} ${bx - px * headHalf},${by - py * headHalf}`}
fill={color}
opacity={0.95}
/>
<text
x={lx}
y={ly}
fontSize={11}
fill={color}
fontWeight={600}
dominantBaseline="middle"
textAnchor={textAnchor}
>
{label} {formatQty(value)}
{unit ? ` ${unit}` : ''}
</text>
</g>
)
}
export function CarnotCycleView({
preset,
disclaimer,
lang,
}: {
preset?: Record<string, number>
title?: string
disclaimer?: string
lang: string
}) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const [tempUnit, setTempUnit] = useState<TempUnit>('K')
const [mode, setMode] = useState<CarnotMode>('fridge')
const [qty, setQty] = useState<CarnotQuantity>('energy')
const [values, setValues] = useState<Record<string, number>>(() => {
const env: Record<string, number> = {}
for (const p of sim.params) env[p.id] = preset?.[p.id] ?? p.defaultValue
return env
})
const phys = useMemo(
() =>
resolveCarnotPhysics(
values.t_cold,
values.t_hot,
modeLoadFromFridgeLoad(values.t_cold, values.t_hot, values.q_cold, mode),
mode
),
[values.t_cold, values.t_hot, values.q_cold, mode]
)
const unitE = qty === 'energy' ? 'kJ' : 'W'
const workName =
qty === 'energy'
? fr
? 'Travail'
: 'Work'
: fr
? 'Puissance'
: 'Power'
const maxRef = Math.max(phys.qh || 0, phys.qc || 0, phys.w || 0, 1)
const cHot = intentColor('warning', dark)
const cCold = intentColor('cache', dark)
const cWork = intentColor('compute', dark)
const text = dark ? '#e4e4e7' : '#27272a'
const cx = SVG_W / 2 - 20
const hotY = 28
const coldY = SVG_H - 42
const midY = SVG_H / 2 - 4
const r = 32
const qOffset = 42
const isEngine = mode === 'engine'
const loadMeta = useMemo(() => {
if (mode === 'fridge') {
return {
symbol: qty === 'energy' ? 'Q_c' : '\\dot{Q}_c',
label: fr ? 'Chaleur extraite (froid)' : 'Heat extracted (cold)',
hint: fr
? 'Charge utile du réfrigérateur'
: 'Useful fridge cooling load',
}
}
if (mode === 'heat_pump') {
return {
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
label: fr ? 'Chaleur fournie (chaud)' : 'Heat delivered (hot)',
hint: fr ? 'Charge utile de la PAC' : 'Useful heat-pump output',
}
}
return {
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
label: fr ? 'Chaleur absorbée (chaud)' : 'Heat absorbed (hot)',
hint: fr ? 'Entrée thermique du moteur' : 'Engine heat input',
}
}, [mode, qty, fr])
const modeLoad = modeLoadFromFridgeLoad(
values.t_cold,
values.t_hot,
values.q_cold,
mode
)
const workSym = qty === 'energy' ? 'W' : 'P'
const qLabel = (base: 'c' | 'h') => (qty === 'energy' ? `Q_${base}` : `Q̇_${base}`)
const lawLine = !phys.ok
? fr
? 'Il faut T_h > T_c (températures absolues).'
: 'Need T_h > T_c (absolute temperatures).'
: fr
? `1ᵉʳ principe : ${qLabel('h')} = ${qLabel('c')} + ${workSym}${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
: `1st law: ${qLabel('h')} = ${qLabel('c')} + ${workSym}${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Segmented
value={mode}
onChange={setMode}
ariaLabel={fr ? 'Mode machine' : 'Machine mode'}
options={MODES.map((m) => ({ id: m.id, label: fr ? m.fr : m.en }))}
/>
<Segmented
value={qty}
onChange={setQty}
ariaLabel={fr ? 'Énergie ou puissance' : 'Energy or power'}
options={QTY.map((q) => ({ id: q.id, label: fr ? q.fr : q.en }))}
/>
<Segmented
value={tempUnit}
onChange={setTempUnit}
ariaLabel={fr ? 'Unité de température' : 'Temperature unit'}
options={TEMP_UNITS.map((u) => ({ id: u.id, label: u.label }))}
/>
</div>
<div className="grid gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<div>
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="mx-auto w-full max-w-md"
role="img"
aria-label={fr ? sim.title.fr : sim.title.en}
>
<rect
x={cx - 105}
y={hotY - 18}
width={210}
height={34}
rx={10}
fill={`${cHot}14`}
stroke={cHot}
strokeWidth={1.5}
/>
<text
x={cx}
y={hotY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cHot}
dominantBaseline="middle"
>
{fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)}
</text>
<rect
x={cx - 105}
y={coldY - 18}
width={210}
height={34}
rx={10}
fill={`${cCold}14`}
stroke={cCold}
strokeWidth={1.5}
/>
<text
x={cx}
y={coldY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cCold}
dominantBaseline="middle"
>
{fr ? 'Source froide' : 'Cold'} · T_c ={' '}
{formatTemp(values.t_cold, tempUnit)}
</text>
<circle
cx={cx}
cy={midY}
r={r}
fill={dark ? '#141820' : '#ffffff'}
stroke={text}
strokeWidth={1.5}
/>
<text
x={cx}
y={midY}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fill={text}
dominantBaseline="middle"
>
{fr ? 'Machine' : 'Engine'}
</text>
{phys.ok && !isEngine ? (
<>
{/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */}
<FlowArrow
x1={cx - qOffset}
y1={coldY - 22}
x2={cx - qOffset}
y2={midY + r + 2}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + qOffset}
y1={midY - r - 2}
x2={cx + qOffset}
y2={hotY + 22}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx + r + 78}
y1={midY}
x2={cx + r + 4}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
{phys.ok && isEngine ? (
<>
{/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */}
<FlowArrow
x1={cx + qOffset}
y1={hotY + 22}
x2={cx + qOffset}
y2={midY - r - 2}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx - qOffset}
y1={midY + r + 2}
x2={cx - qOffset}
y2={coldY - 22}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + r + 4}
y1={midY}
x2={cx + r + 78}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
<text x={10} y={SVG_H - 10} fontSize={10} fill={dark ? '#8b8b93' : '#6b7280'}>
{fr
? qty === 'energy'
? 'Épaisseur ∝ énergie (kJ) — W = travail (pas le watt)'
: 'Épaisseur ∝ puissance — P et W (watt) = même unité ici'
: qty === 'energy'
? 'Thickness ∝ energy (kJ) — W = work (not the watt)'
: 'Thickness ∝ power — P uses watts'}
</text>
</svg>
<p className="mt-2 text-center text-[11px] text-muted-foreground">{lawLine}</p>
{phys.ok && phys.entropyOk ? (
<p className="mt-1 text-center text-[10px] text-muted-foreground/80">
{fr
? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h'
: '2nd law (reversible): Q_c/T_c = Q_h/T_h'}
</p>
) : null}
</div>
<div className="space-y-3">
<div>
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{sim.params
.filter((p) => p.id === 't_cold' || p.id === 't_hot')
.map((p) => {
const meta = tempSliderMeta(p, tempUnit)
const displayVal = kelvinToDisplay(values[p.id], tempUnit)
return (
<SimSlider
key={`${p.id}-${tempUnit}`}
symbol={p.symbol}
label={fr ? p.label.fr : p.label.en}
unit={meta.unitLabel}
min={meta.min}
max={meta.max}
step={meta.step}
value={
tempUnit === 'K'
? Math.round(displayVal)
: Math.round(displayVal * 10) / 10
}
intent={p.intent}
onChange={(v) => {
const k = displayToKelvin(v, tempUnit)
const clamped = Math.min(p.max, Math.max(p.min, k))
setValues((s) => ({ ...s, [p.id]: clamped }))
}}
/>
)
})}
<SimSlider
key={`load-${mode}-${qty}`}
symbol={loadMeta.symbol}
label={loadMeta.label}
unit={unitE}
min={10}
max={500}
step={5}
value={Math.round(modeLoad * 10) / 10}
intent="flow"
onChange={(v) => {
const fridgeQc = fridgeLoadFromModeLoad(
values.t_cold,
values.t_hot,
v,
mode
)
setValues((s) => ({
...s,
q_cold: Math.min(500, Math.max(10, fridgeQc)),
}))
}}
/>
<p className="px-1 text-[10px] text-muted-foreground">{loadMeta.hint}</p>
</div>
</div>
<div>
<SimHeading>
{fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'}
</SimHeading>
<div className="grid grid-cols-2 gap-2">
{mode === 'fridge' || mode === 'heat_pump' ? (
<>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{R}`}
label={fr ? 'COP frigo' : 'Fridge COP'}
value={phys.copR}
intent="output"
digits={2}
/>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{HP}`}
label={fr ? 'COP PAC' : 'Heat-pump COP'}
value={phys.copHP}
intent="output"
digits={2}
/>
</>
) : (
<SimOutputCard
symbol={String.raw`\eta`}
label={fr ? 'Rendement moteur' : 'Engine efficiency'}
value={phys.eta * 100}
unit="%"
intent="highlight"
digits={1}
/>
)}
<SimOutputCard
symbol={workSym}
label={
isEngine
? fr
? `${workName} fourni`
: `${workName} out`
: fr
? `${workName} minimale`
: `Minimum ${workName.toLowerCase()}`
}
value={phys.w}
unit={unitE}
intent="compute"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_h` : String.raw`\dot{Q}_h`
}
label={
isEngine
? fr
? 'Chaleur absorbée'
: 'Heat absorbed'
: fr
? 'Chaleur côté chaud'
: 'Hot-side heat'
}
value={phys.qh}
unit={unitE}
intent="flow"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_c` : String.raw`\dot{Q}_c`
}
label={
isEngine
? fr
? 'Chaleur rejetée (froid)'
: 'Heat rejected (cold)'
: fr
? 'Chaleur côté froid'
: 'Cold-side heat'
}
value={phys.qc}
unit={unitE}
intent="cache"
digits={1}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{qty === 'energy'
? fr
? 'W = travail (énergie en kJ), pas le watt. Passe en « Puissance (W) » pour raisonner en watts.'
: 'W = work (energy in kJ), not the watt. Switch to “Power (W)” to use watts.'
: fr
? 'Mode puissance : P, Q̇_c et Q̇_h sont en watts (W). Les COP / η restent sans unité.'
: 'Power mode: P, Q̇_c and Q̇_h are in watts (W). COP / η stay dimensionless.'}
</p>
</div>
</div>
</div>
{disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import { useMemo, useState } from 'react'
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Bar,
BarChart,
} from 'recharts'
import type { GenericFormulaSim } from '@/lib/interactive-page'
import { parseSimExpr } from '@/lib/interactive-page/sim-eval'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
const CURVE_SAMPLES = 60
/** Generic slider-driven formula simulator (safe exprs, no eval). */
export function GenericFormulaView({
sim,
lang,
}: {
sim: GenericFormulaSim
lang: string
}) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const [values, setValues] = useState<Record<string, number>>(() => {
const env: Record<string, number> = {}
for (const p of sim.params) env[p.id] = p.defaultValue
return env
})
const compiled = useMemo(
() =>
sim.computed.map((c) => ({
def: c,
parsed: parseSimExpr(c.expr),
})),
[sim.computed]
)
const results = useMemo(() => {
const env = { ...values }
const out: Record<string, number> = {}
for (const c of compiled) {
const v = 'message' in c.parsed ? NaN : c.parsed.evaluate(env)
env[c.def.id] = v
out[c.def.id] = v
}
return out
}, [compiled, values])
const visual = sim.visual
const curve = useMemo(() => {
if (visual.kind !== 'curve') return null
const xParam = sim.params.find((p) => p.id === visual.xParamId)
const parsed = parseSimExpr(visual.expr)
if (!xParam || 'message' in parsed) return null
// Full env: params + chained computed (visual.expr may reference computed ids)
const fullEnv = { ...values, ...results }
const pts: { x: number; y: number }[] = []
for (let i = 0; i <= CURVE_SAMPLES; i++) {
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
const y = parsed.evaluate({ ...fullEnv, [xParam.id]: x })
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
}
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(fullEnv) }
}, [visual, sim.params, values, results])
const accent = intentColor('highlight', dark)
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
return (
<div>
{sim.intro ? (
<p className="mb-4 text-[15px] leading-relaxed text-muted-foreground">
{sim.intro}
</p>
) : null}
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{sim.params.map((p) => (
<SimSlider
key={p.id}
symbol={p.symbol}
label={p.label}
unit={p.unit}
min={p.min}
max={p.max}
step={p.step}
value={values[p.id]}
intent={p.intent}
onChange={(v) => setValues((s) => ({ ...s, [p.id]: v }))}
/>
))}
</div>
</div>
<div className="space-y-3">
<SimHeading>{fr ? 'Résultats' : 'Results'}</SimHeading>
{sim.visual.kind === 'gauges' ? (
<div className="space-y-2">
{sim.computed.map((c) => {
const v = results[c.id]
const maxRef = Math.max(
...sim.computed.map((o) => Math.abs(results[o.id] || 0)),
1
)
const color = intentColor(c.intent, dark)
return (
<div key={c.id} className="rounded-xl border border-border/50 bg-background/70 px-3 py-2">
<div className="flex items-baseline justify-between text-xs">
<span className="text-muted-foreground">{c.label}</span>
<span className="font-semibold tabular-nums" style={{ color }}>
{Number.isFinite(v) ? v.toFixed(2) : '—'}
{c.unit ? ` ${c.unit}` : ''}
</span>
</div>
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full transition-[width] duration-150"
style={{
width: `${Math.min(100, (Math.abs(v || 0) / maxRef) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</div>
)
})}
</div>
) : null}
{sim.visual.kind === 'bars' ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sim.computed.map((c) => ({
name: c.label,
value: Number.isFinite(results[c.id]) ? results[c.id] : 0,
}))}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="name" tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Bar dataKey="value" fill={accent} radius={[4, 4, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
) : null}
{sim.visual.kind === 'curve' && curve ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={curve.pts} margin={{ top: 8, right: 8, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis
dataKey="x"
type="number"
domain={[curve.xParam.min, curve.xParam.max]}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => String(Math.round(v))}
/>
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Line type="monotone" dataKey="y" stroke={accent} strokeWidth={2.5} dot={false} isAnimationActive={false} />
{Number.isFinite(curve.currentY) ? (
<ReferenceDot x={curve.currentX} y={curve.currentY} r={5} fill={accent} stroke="none" />
) : null}
</LineChart>
</ResponsiveContainer>
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
{sim.computed.map((c) => (
<SimOutputCard
key={c.id}
symbol={c.symbol}
label={c.label}
value={results[c.id]}
unit={c.unit}
intent={c.intent}
/>
))}
</div>
</div>
</div>
{sim.disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{sim.disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,27 @@
import type { ComponentType } from 'react'
import { CarnotCycleView } from './carnot-cycle-view'
import { CarnotCycleAnimView } from './carnot-cycle-anim-view'
import { TsDiagramView } from './ts-diagram-view'
export type CatalogSimViewProps = {
preset?: Record<string, number>
title?: string
disclaimer?: string
lang: string
}
export type CatalogAnimViewProps = {
step: number
lang: string
}
/** Registry: catalog simId → bespoke slider-simulation view. */
export const SIMULATOR_VIEWS: Record<string, ComponentType<CatalogSimViewProps>> = {
'carnot-cycle': CarnotCycleView,
}
/** Registry: catalog simId → bespoke animated scene (step-driven). */
export const ANIM_VIEWS: Record<string, ComponentType<CatalogAnimViewProps>> = {
'carnot-cycle-anim': CarnotCycleAnimView,
'ts-diagram': TsDiagramView,
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId } from '@/lib/interactive-demo/types'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { cn } from '@/lib/utils'
export function SimKaTeX({ tex, className }: { tex: string; className?: string }) {
const html = useMemo(() => {
try {
// output:'html' avoids MathML annotation leaking as visible "mathrm{…}" in flex layouts
return katex.renderToString(tex, { throwOnError: false, output: 'html' })
} catch {
return tex
}
}, [tex])
return (
<span
className={cn('inline-block max-w-full [&_.katex]:text-[1em]', className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function SimSlider({
symbol,
label,
unit,
min,
max,
step,
value,
intent,
onChange,
}: {
symbol: string
label: string
unit?: string
min: number
max: number
step: number
value: number
intent?: IntentId
onChange: (v: number) => void
}) {
const dark = useDarkMode()
const accent = intentColor(intent, dark)
return (
<label className="block rounded-xl border border-border/50 bg-background/70 px-3 py-2.5">
<span className="flex items-baseline justify-between gap-2">
<span className="flex items-baseline gap-2 text-sm font-medium">
<SimKaTeX tex={symbol} />
<span className="text-xs text-muted-foreground">{label}</span>
</span>
<span
className="rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums"
style={{ color: accent, backgroundColor: `${accent}18` }}
>
{value}
{unit ? ` ${unit}` : ''}
</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sim-slider mt-2 w-full"
style={{ accentColor: accent }}
aria-label={label}
/>
<span className="mt-0.5 flex justify-between text-[10px] tabular-nums text-muted-foreground/70">
<span>
{min}
{unit ? ` ${unit}` : ''}
</span>
<span>
{max}
{unit ? ` ${unit}` : ''}
</span>
</span>
</label>
)
}
export function SimOutputCard({
symbol,
label,
value,
unit,
intent,
digits = 2,
}: {
symbol: string
label: string
value: number
unit?: string
intent?: IntentId
digits?: number
}) {
const dark = useDarkMode()
const accent = intentColor(intent, dark)
const finite = Number.isFinite(value)
return (
<div className="rounded-xl border border-border/50 bg-background/70 px-3 py-2.5 text-center">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-1 flex items-baseline justify-center gap-1.5">
<SimKaTeX tex={symbol} className="text-sm" />
<span
className={cn('text-lg font-semibold tabular-nums', !finite && 'text-muted-foreground')}
style={finite ? { color: accent } : undefined}
>
{finite ? value.toFixed(digits) : '—'}
{finite && unit ? ` ${unit}` : ''}
</span>
</div>
</div>
)
}
/** Small section heading inside a simulator card. */
export function SimHeading({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<p
className={cn(
'mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground',
className
)}
>
{children}
</p>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Ts diagram of the Carnot cycle — canonical 2nd-law diagram.
* Driven by AnimPlayerShell via `step` (0..4). SVG, transform/opacity only.
* Cycle = rectangle: horizontal isotherms (Th, Tc), vertical adiabatics.
*/
const X0 = 110 // y-axis x
const Y0 = 280 // x-axis y
const S1 = 190 // entropy left
const S2 = 520 // entropy right
const YH = 80 // T_h line y
const YC = 220 // T_c line y
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
// marker position per beat (end of each phase)
const MARKER = [
{ x: S2, y: YH }, // b1 end: right-top
{ x: S2, y: YC }, // b2 end: right-bottom
{ x: S1, y: YC }, // b3 end: left-bottom
{ x: S1, y: YH }, // b4 end: left-top
{ x: S1, y: YH }, // b5: stay
]
export function TsDiagramView({ step, lang }: { step: number; lang: string }) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const s = Math.min(step, 4)
const ink = dark ? '#EDE7DB' : '#242422'
const muted = dark ? '#A39C8D' : '#686762'
const plum = dark ? '#D07AA6' : '#9F3F70'
const blue = dark ? '#7FA8CC' : '#3F6F9F'
const paper = dark ? '#17140F' : '#F4F0E8'
const m = MARKER[s]
const qhW = S2 - S1
return (
<svg
viewBox="0 0 680 320"
className="w-full"
role="img"
aria-label={fr ? 'Diagramme Ts du cycle de Carnot' : 'Ts diagram of the Carnot cycle'}
style={{ background: paper, display: 'block' }}
>
{/* axes */}
<line x1={X0} y1={Y0} x2={650} y2={Y0} stroke={ink} strokeWidth={1.5} />
<line x1={X0} y1={Y0} x2={X0} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={648} y={Y0 + 18} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">s</text>
<text x={X0 - 8} y={40} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">T</text>
{/* isotherm T_h */}
<line x1={X0} y1={YH} x2={650} y2={YH} stroke={plum} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YH + 4} fontSize={11} fill={plum} textAnchor="end" fontWeight={600}>T_h</text>
{/* isotherm T_c */}
<line x1={X0} y1={YC} x2={650} y2={YC} stroke={blue} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YC + 4} fontSize={11} fill={blue} textAnchor="end" fontWeight={600}>T_c</text>
{/* Q_h area (beat ≥ 0, shown from beat 0) */}
<rect
x={S1} y={YH} width={qhW} height={Y0 - YH}
fill={plum} opacity={s >= 0 ? 0.10 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 0 ? (
<text x={(S1 + S2) / 2} y={(YH + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={plum}>Q_h</text>
) : null}
{/* Q_c area (from beat 2) */}
<rect
x={S1} y={YC} width={qhW} height={Y0 - YC}
fill={blue} opacity={s >= 2 ? 0.16 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s >= 2 && s < 4 ? (
<text x={(S1 + S2) / 2} y={(YC + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={blue}>Q_c</text>
) : null}
{/* W = cycle area (beat 4) */}
<rect
x={S1} y={YH} width={qhW} height={YC - YH}
fill={plum} opacity={s === 4 ? 0.18 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 4 ? (
<text x={(S1 + S2) / 2} y={(YH + YC) / 2} textAnchor="middle" fontSize={17} fontWeight={800} fill={plum}>W</text>
) : null}
{/* cycle edges, drawn progressively */}
{/* top: b1 (isothermal expansion) */}
<line x1={S1} y1={YH} x2={S2} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 0 ? 1 : 0.15} />
<polygon points={`${S2 - 14},${YH - 5} ${S2 - 4},${YH} ${S2 - 14},${YH + 5}`} fill={ink} opacity={s >= 0 ? 1 : 0.15} />
{/* right: b2 (adiabatic expansion) */}
<line x1={S2} y1={YH} x2={S2} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S2 - 5},${YC - 14} ${S2},${YC - 4} ${S2 + 5},${YC - 14}`} fill={ink} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* bottom: b3 (isothermal compression) */}
<line x1={S2} y1={YC} x2={S1} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 + 14},${YC - 5} ${S1 + 4},${YC} ${S1 + 14},${YC + 5}`} fill={ink} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* left: b4 (adiabatic compression) */}
<line x1={S1} y1={YC} x2={S1} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 - 5},${YH + 14} ${S1},${YH + 4} ${S1 + 5},${YH + 14}`} fill={ink} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* entropy ticks */}
<text x={S1} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
<text x={S2} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
{/* state marker */}
<circle cx={m.x} cy={m.y} r={6} fill={plum} stroke={paper} strokeWidth={2} style={{ transition: EASE }} />
</svg>
)
}

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