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.
- **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.updated`
- `customer.subscription.deleted`
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin dessai)
- `invoice.payment_failed`
4. Copier la **signature secrète**`STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
| Événement Stripe | Action Memento | Statut Prisma |
|------------------|---------------|---------------|
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
| `customer.subscription.trial_will_end` | Sync + email rappel fin dessai | `TRIALING` |
### 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
-**22 Tools** for notes, notebooks, labels, and reminders
-**29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
- 🔒 **API Key Authentication** with secure storage
- 🚀 **Performance Optimized** with connection pooling and caching
- 📊 **Observability** with Prometheus metrics export
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
```
## Available Tools (22)
## Available Tools (29)
### Notes (13)
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `batch_move_notes` | Move multiple notes at once |
| `batch_delete_notes` | Delete multiple notes at once |
### Notebooks (6)
### Notebooks (7)
| Tool | Description |
|------|-------------|
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `update_label` | Update a label |
| `delete_label` | Delete a label |
### Reminders (1)
### Reminders (3)
| Tool | Description |
|------|-------------|
| `get_due_reminders` | Get due reminders |
| `get_upcoming_reminders` | Get reminders due in the next N hours |
| `update_reminder` | Set, update, or clear a note reminder |
### Semantic & AI (3)
| Tool | Description |
|------|-------------|
| `find_similar_notes` | Find notes semantically similar to a given note |
| `get_memory_echo_insights` | List AI-discovered note connections |
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
### Filters & Statistics (2)
| Tool | Description |
|------|-------------|
| `get_notes_by_label` | List notes by label/tag |
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
### Utilities (2)

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "memento-mcp-server",
"version": "3.2.0",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
"type": "module",
"main": "index.js",
"scripts": {

View File

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

View File

@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -463,6 +463,100 @@ const toolDefinitions = [
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_upcoming_reminders',
description: 'Get notes with reminders due in the next N hours (default 24).',
inputSchema: {
type: 'object',
properties: {
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
},
},
{
name: 'update_reminder',
description: 'Set, update, or clear a reminder on a note.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
reminderLocation: { type: 'string', nullable: true },
},
required: ['id'],
},
},
// ═══ SEMANTIC / AI ═══
{
name: 'find_similar_notes',
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Source note ID' },
limit: { type: 'number', description: 'Max results', default: 10 },
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
},
required: ['id'],
},
},
{
name: 'get_memory_echo_insights',
description: 'List Memory Echo insights: AI-discovered connections between notes.',
inputSchema: {
type: 'object',
properties: {
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
limit: { type: 'number', description: 'Max results', default: 20 },
},
},
},
{
name: 'dismiss_memory_echo_insight',
description: 'Mark a Memory Echo insight as viewed or dismissed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Insight ID' },
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
},
required: ['id'],
},
},
// ═══ FILTERS & STATISTICS ═══
{
name: 'get_notes_by_label',
description: 'List notes that have a specific label/tag.',
inputSchema: {
type: 'object',
properties: {
label: { type: 'string', description: 'Label text to match' },
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
includeArchived: { type: 'boolean', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
required: ['label'],
},
},
{
name: 'get_note_statistics',
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
},
},
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
title: args.title || null,
content: args.content,
color: args.color || 'default',
type: args.type || 'text',
type: args.type || 'richtext',
checkItems: args.checkItems ?? null,
labels: args.labels ?? null,
isPinned: args.isPinned || false,
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders });
}
case 'get_upcoming_reminders': {
const now = new Date();
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
const extra = {
reminder: { not: null, gte: now, lte: horizon },
isArchived: false,
};
if (!args.includeDone) extra.isReminderDone = false;
const reminders = await prisma.note.findMany({
where: noteWhere(uid, extra),
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: args.limit || 100,
});
return textResult({ count: reminders.length, horizon, reminders });
}
case 'update_reminder': {
const d = { updatedAt: new Date() };
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
const note = await prisma.note.update({
where: { id: args.id, userId: uid, trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
// ═══ SEMANTIC / AI ═══
case 'find_similar_notes': {
const source = await prisma.note.findUnique({
where: { id: args.id, userId: uid, trashedAt: null },
select: { id: true, title: true },
});
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
where: { noteId: args.id },
select: { embedding: true },
});
if (!sourceEmbedding) {
return textResult({
noteId: args.id,
error: 'Source note has no embedding. Index it first via the Memento app.',
results: [],
});
}
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
? sourceEmbedding.embedding
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
const vecStr = `[${embeddingArray.join(',')}]`;
const limit = Math.min(args.limit || 10, 50);
const threshold = args.threshold ?? 0.7;
const rows = await prisma.$queryRawUnsafe(
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
n."notebookId", n."createdAt", n."updatedAt",
1 - (e."embedding"::vector <=> $1::vector) AS similarity
FROM "Note" n
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
WHERE n."trashedAt" IS NULL
AND n."isArchived" = false
AND n."userId" = $2
AND n.id != $3
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
ORDER BY e."embedding"::vector <=> $1::vector ASC
LIMIT $5`,
vecStr,
uid,
args.id,
threshold,
limit,
);
return textResult({
noteId: args.id,
sourceTitle: source.title || 'Untitled',
count: rows.length,
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
});
}
case 'get_memory_echo_insights': {
const where = { userId: uid };
if (!args.includeViewed) where.viewed = false;
if (!args.includeDismissed) where.dismissed = false;
const insights = await prisma.memoryEchoInsight.findMany({
where,
orderBy: { similarityScore: 'desc' },
take: Math.min(args.limit || 20, 100),
include: {
note1: { select: { id: true, title: true } },
note2: { select: { id: true, title: true } },
},
});
return textResult({
count: insights.length,
insights: insights.map(i => ({
id: i.id,
insight: i.insight,
similarityScore: i.similarityScore,
insightDate: i.insightDate,
viewed: i.viewed,
dismissed: i.dismissed,
note1: i.note1,
note2: i.note2,
})),
});
}
case 'dismiss_memory_echo_insight': {
const d = {};
if ('viewed' in args) d.viewed = args.viewed;
if ('dismissed' in args) d.dismissed = args.dismissed;
const insight = await prisma.memoryEchoInsight.update({
where: { id: args.id, userId: uid },
data: d,
});
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
}
// ═══ FILTERS & STATISTICS ═══
case 'get_notes_by_label': {
const label = args.label.trim();
const extra = {};
if (!args.includeArchived) extra.isArchived = false;
if (args.notebookId) {
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
// Labels are stored as a JSON string like ["tag1","tag2"].
const notes = await prisma.note.findMany({
where: {
...noteWhere(uid, extra),
labels: { contains: label },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
});
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
}
case 'get_note_statistics': {
const baseWhere = { userId: uid };
const activeWhere = { ...baseWhere, trashedAt: null };
const [
totalNotes,
archivedNotes,
trashedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
notesByType,
notesByColor,
] = await Promise.all([
prisma.note.count({ where: activeWhere }),
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
prisma.notebook.count({ where: { userId: uid } }),
prisma.label.count({ where: { notebook: { userId: uid } } }),
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
]);
return textResult({
totalNotes,
archivedNotes,
trashedNotes,
activeNotes: totalNotes - archivedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
});
}
// ═══ TRASH ═══
case 'trash_note': {
const note = await prisma.note.update({

View File

@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
fullDetails: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
offset: z.number().int().min(0).default(0),
});
/**
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
*/
export const searchNotesSchema = z.object({
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(50),
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
});
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
*/
export const togglePinSchema = z.object({
id: idSchema,
pinned: z.boolean().optional(),
});
/**
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
*/
export const toggleArchiveSchema = z.object({
id: idSchema,
archived: z.boolean().optional(),
});
/**
* batch_move_notes input schema
*/
export const batchMoveNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
notebookId: idSchema.optional().nullable(),
});
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
* batch_delete_notes input schema
*/
export const batchDeleteNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
});
/**
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
*/
export const createNotebookSchema = z.object({
name: z.string().min(1).max(200),
color: colorSchema.default('default'),
color: z.string().max(50).default('#3B82F6'),
icon: z.string().max(50).optional().nullable(),
parentId: idSchema.optional().nullable(),
order: z.number().int().optional(),
});
/**
* get_notebooks input schema
*/
export const getNotebooksSchema = z.object({
includeHierarchy: boolSchema(false),
includeTrashed: boolSchema(false),
});
export const getNotebooksSchema = z.object({});
/**
* get_notebook input schema
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
export const updateNotebookSchema = z.object({
id: idSchema,
name: z.string().min(1).max(200).optional(),
color: colorSchema.optional(),
color: z.string().max(50).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
order: z.number().int().optional(),
parentId: idSchema.optional().nullable(),
});
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
*/
export const createLabelSchema = z.object({
name: z.string().min(1).max(100),
color: colorSchema.default('default'),
color: z.string().max(50).optional().nullable(),
notebookId: idSchema,
});
/**
* get_labels input schema
*/
export const getLabelsSchema = z.object({
limit: z.number().int().min(1).max(500).default(100),
notebookId: idSchema.optional().nullable(),
});
/**
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
/**
* get_due_reminders input schema
*/
export const getDueRemindersSchema = z.object({
before: isoDateSchema.optional().nullable(),
after: isoDateSchema.optional().nullable(),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
export const getDueRemindersSchema = z.object({});
/**
* export_notes input schema
*/
export const exportNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
format: z.enum(['json', 'markdown']).default('json'),
});
export const exportNotesSchema = z.object({});
/**
* import_notes input schema
*/
export const importNotesSchema = z.object({
notes: z.array(
z.object({
title: z.string().optional(),
content: z.string(),
color: colorSchema.optional(),
labels: labelsSchema,
data: z.object({
version: z.string().optional(),
data: z.object({
notes: z.array(z.any()).optional(),
labels: z.array(z.any()).optional(),
notebooks: z.array(z.any()).optional(),
}).optional(),
}),
});
/**
* find_similar_notes input schema
*/
export const findSimilarNotesSchema = z.object({
id: idSchema,
limit: z.number().int().min(1).max(50).default(10),
threshold: z.number().min(0).max(1).default(0.7),
});
/**
* get_memory_echo_insights input schema
*/
export const getMemoryEchoInsightsSchema = z.object({
includeViewed: boolSchema(false),
includeDismissed: boolSchema(false),
limit: z.number().int().min(1).max(100).default(20),
});
/**
* dismiss_memory_echo_insight input schema
*/
export const dismissMemoryEchoInsightSchema = z.object({
id: idSchema,
viewed: boolSchema(true),
dismissed: boolSchema(true),
});
/**
* get_notes_by_label input schema
*/
export const getNotesByLabelSchema = z.object({
label: z.string().min(1).max(100),
notebookId: idSchema.optional().nullable(),
})
).min(1).max(100),
notebookId: idSchema.optional().nullable(),
overwrite: z.boolean().optional().default(false),
includeArchived: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* get_note_statistics input schema
*/
export const getNoteStatisticsSchema = z.object({
includeArchived: boolSchema(false),
includeTrashed: boolSchema(false),
});
/**
* get_upcoming_reminders input schema
*/
export const getUpcomingRemindersSchema = z.object({
hours: z.number().int().min(1).max(168).default(24),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* update_reminder input schema
*/
export const updateReminderSchema = z.object({
id: idSchema,
reminder: isoDateSchema,
isReminderDone: z.boolean().optional(),
reminderRecurrence: recurrenceSchema,
reminderLocation: z.string().max(500).optional().nullable(),
});
// ═══════════════════════════════════════════════════════════════
@@ -427,6 +476,13 @@ export const toolSchemas = {
get_due_reminders: getDueRemindersSchema,
export_notes: exportNotesSchema,
import_notes: importNotesSchema,
find_similar_notes: findSimilarNotesSchema,
get_memory_echo_insights: getMemoryEchoInsightsSchema,
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
get_notes_by_label: getNotesByLabelSchema,
get_note_statistics: getNoteStatisticsSchema,
get_upcoming_reminders: getUpcomingRemindersSchema,
update_reminder: updateReminderSchema,
};
// ═══════════════════════════════════════════════════════════════
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map((e) => ({
errors: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,

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 { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
import {
CreditCard,
Gauge,
Coins,
Package,
Activity,
CheckCircle2,
XCircle,
AlertTriangle,
Users,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PriceHealth = {
key: string
priceId: string
configured: boolean
source: string
stripeAmount: number | null
stripeCurrency: string | null
stripeActive: boolean | null
error: string | null
}
type BillingAdminData = {
billingConfig: Record<string, string>
@@ -19,7 +44,6 @@ type BillingAdminData = {
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
@@ -31,6 +55,35 @@ type BillingAdminData = {
credits: number
defaultDisplay: string
}>
stripeHealth?: {
secretConfigured: boolean
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
publishableConfigured: boolean
webhookSecretConfigured: boolean
billingEnabled: boolean
trialDays: number
prices: PriceHealth[]
}
subscriptionStats?: {
byTier: Record<string, number>
byStatus: Record<string, number>
cancelAtPeriodEnd: number
usersWithoutSub: number
trialing: number
pastDue: number
paidActive: number
recent: Array<{
email: string
name: string | null
tier: string
status: string
trialEndsAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
hasStripeSub: boolean
updatedAt: string
}>
}
}
const SUBSCRIPTION_PRICE_KEYS = [
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [showTestGuide, setShowTestGuide] = useState(true)
const health = initialData.stripeHealth
const stats = initialData.subscriptionStats
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const modeLabel =
health?.secretMode === 'test'
? t('admin.billing.modeTest')
: health?.secretMode === 'live'
? t('admin.billing.modeLive')
: health?.secretMode === 'placeholder'
? t('admin.billing.modePlaceholder')
: t('admin.billing.modeMissing')
return (
<div className="space-y-8">
<div>
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div>
{/* Stripe health */}
{health && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Activity className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
</div>
</div>
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
<div className="min-w-0">
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.publishableConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
<p className="text-[11px] text-muted-foreground">
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.webhookSecretConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
<p className="text-[11px] text-muted-foreground">
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
<p className="text-[11px] text-muted-foreground">
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
</p>
</div>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
</tr>
</thead>
<tbody>
{(health.prices ?? []).map((row) => (
<tr key={row.key} className="border-t border-border/50">
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
<td className="p-2.5 font-mono truncate max-w-[180px]">
{row.configured ? row.priceId : '—'}
</td>
<td className="p-2.5">{row.source}</td>
<td className="p-2.5">
{!row.configured ? (
<span className="text-rose-600">{t('admin.billing.missing')}</span>
) : row.error ? (
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
) : row.stripeAmount != null ? (
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
</span>
) : (
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Subscription stats */}
{stats && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Users className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
</div>
</div>
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.entries(stats.byTier).map(([tier, count]) => (
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
<span>{tier}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))}
<li className="flex justify-between text-xs text-muted-foreground pt-1">
<span>{t('admin.billing.usersWithoutSub')}</span>
<span className="tabular-nums">{stats.usersWithoutSub}</span>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.keys(stats.byStatus).length === 0 ? (
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
) : (
Object.entries(stats.byStatus).map(([status, count]) => (
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
<span>{status}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))
)}
</ul>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
{stats.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
) : (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
</tr>
</thead>
<tbody>
{stats.recent.map((row) => (
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
<td className="p-2.5">{row.tier}</td>
<td className="p-2.5">
{row.status}
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
</td>
<td className="p-2.5 text-muted-foreground">
{row.trialEndsAt
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
: row.currentPeriodEnd
? formatStableUtc(row.currentPeriodEnd)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* How to test Stripe */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setShowTestGuide((v) => !v)}
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<BookOpen className="h-5 w-5" />
</div>
<div className="flex-1">
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
</div>
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showTestGuide && (
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
<ol className="list-decimal ps-5 space-y-2">
<li>{t('admin.billing.testStep1')}</li>
<li>{t('admin.billing.testStep2')}</li>
<li>
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
{' — '}{t('admin.billing.testStep3')}
</li>
<li>{t('admin.billing.testStep4')}</li>
<li>{t('admin.billing.testStep5')}</li>
<li>{t('admin.billing.testStep6')}</li>
</ol>
<p className="text-xs border-t border-border/50 pt-3">
{t('admin.billing.testCardHint')}
</p>
</div>
)}
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -106,7 +418,9 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
const meta = priceMap[key]
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
@@ -115,8 +429,17 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
{meta?.stripeAmount != null && (
<p className="text-[11px] text-muted-foreground">
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
</p>
)}
{meta?.error && (
<p className="text-[11px] text-rose-600">{meta.error}</p>
)}
</div>
))}
)
})}
</div>
</div>
<div className="border-t border-border/50 pt-4">
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}

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';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
function AuthHeader() {
const { t } = useLanguage();
return (
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
aria-label={t('general.back')}
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
);
}
export default function AuthLayout({
export default async function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const headersList = await headers();
const browserLang = parseAcceptLanguage(headersList.get('accept-language'));
const initialLanguage = await detectUserLanguage(browserLang);
const initialTranslations = await loadTranslations(initialLanguage);
return (
<LanguageProvider>
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<AuthHeader />
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2026 Memento
</p>
</div>
</main>
</div>
<LanguageProvider initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<AuthShell>{children}</AuthShell>
</LanguageProvider>
);
}

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 dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import {
@@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
@@ -102,7 +103,7 @@ export default function InsightsPage() {
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('list')
const [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */
@@ -129,6 +130,15 @@ export default function InsightsPage() {
loadInitialData()
}, [])
useEffect(() => {
const clusterParam = searchParams.get('cluster')
if (!clusterParam || clusters.length === 0) return
const match = clusters.find(
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
)
if (match) setSelectedClusterId(match.id)
}, [searchParams, clusters])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(
@@ -435,7 +445,7 @@ export default function InsightsPage() {
<button
className="p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
aria-label="Toggle sidebar"
aria-label={t('insightsView.toggleMenu')}
>
<Menu size={22} />
</button>
@@ -707,7 +717,7 @@ export default function InsightsPage() {
style={{ backgroundColor: cluster.color }}
aria-hidden
/>
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
<h3 className="min-w-0 flex-1 text-[13px] font-semibold leading-snug text-ink dark:text-dark-ink">
{name}
</h3>
<span className="text-[9px] text-concrete shrink-0 tabular-nums">

View File

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

View File

@@ -2,6 +2,7 @@
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings'
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
import { useLanguage } from '@/lib/i18n'
export default function SettingsLayout({
@@ -11,9 +12,10 @@ export default function SettingsLayout({
}) {
const { t } = useLanguage()
return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<div className="flex flex-col h-full bg-background">
<SettingsDocumentTitle />
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
<div className="flex items-start gap-3 mb-6">
<button
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
@@ -22,10 +24,10 @@ export default function SettingsLayout({
<Menu size={22} />
</button>
<div>
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
{t('settings.title')}
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
{t('settings.description')}
</p>
</div>

View File

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

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

View File

@@ -1,89 +1,160 @@
'use client'
import { motion } from 'motion/react'
import { Shield } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { Check } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLanguage } from '@/lib/i18n'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
export default function PricingPage() {
const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
const res = await fetch('/api/public/byok-catalog')
if (!res.ok) throw new Error('catalog')
return res.json() as Promise<{ providers: { id: string }[] }>
},
staleTime: 60_000,
})
const providerCount = byokCatalog?.providers.length || '…'
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
const plans = [
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
return (
<main className="min-h-screen bg-paper">
<section className="py-32 px-8">
<div className="max-w-7xl mx-auto">
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="flex items-center gap-2 sm:gap-3">
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</Link>
<Link
href="/register"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
>
{t('landing.nav.cta')}
</Link>
</div>
</nav>
<section className="px-5 sm:px-8 py-28">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
{t('landing.pricing.label')}
</span>
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.monthly')}
</button>
<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
type="button"
onClick={() => setBillingInterval('annual')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{plans.map((plan) => (
<div
key={plan.key}
className={`rounded-2xl border p-6 flex flex-col ${
plan.popular
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
: 'border-white/[0.08] bg-white/[0.02]'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
{t('landing.pricing.popular')}
</div>
</span>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
{t(`landing.pricing.${plan.key}.name`)}
</h2>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
</div>
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
</div>
<div className="flex-1 space-y-4 mb-10">
{[0, 1, 2, 3, 4, 5].map(j => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
if (!feat || feat.startsWith('landing.')) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
<li key={j} className="flex gap-2 text-sm text-white/80">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat}
</li>
)
})}
</div>
<button
onClick={() => router.push('/register')}
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
</ul>
<Link
href="/register"
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
</button>
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link>
</div>
))}
</div>

View File

@@ -2,13 +2,24 @@ import { headers } from 'next/headers'
import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export const metadata = {
title: 'Privacy Policy — Memento',
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
export async function generateMetadata({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
return {
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
description: locale === 'fr'
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
}
}
type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = []
@@ -353,13 +364,15 @@ export default async function PrivacyPage({
</header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">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>
<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>
<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'}
</p>
<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() {
await checkAdmin()
const { getSystemConfig } = await import('@/lib/config')
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
}
})
const [stripeHealth, subscriptionStats] = await Promise.all([
getStripeHealth(billingConfig),
getSubscriptionStats(),
])
return {
entitlements,
billingConfig,
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
creditAllocations,
creditCosts,
creditPacks,
stripeHealth,
subscriptionStats,
}
}

View File

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

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 { AuthError } from 'next-auth';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
const emailRaw = formData.get('email');
const passwordRaw = formData.get('password');
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
// Surface a clear message when credentials are valid but email is unverified.
if (email && password.length >= 6) {
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user?.password) {
const match = await bcrypt.compare(password, user.password);
if (match && !user.emailVerified) {
return 'EMAIL_NOT_VERIFIED';
}
}
} catch (preCheckErr) {
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
}
}
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
email,
password,
redirectTo: '/home',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
if (
error.type === 'CredentialsSignin' &&
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
) {
return 'EMAIL_NOT_VERIFIED';
}
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';

View File

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

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 { z } from 'zod'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { countPlainWords, MIN_WORDS_FOR_TITLE_SUGGESTION, stripHtmlToPlainText } from '@/lib/text/plain-text'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
/** Supprime les balises HTML pour extraire le texte brut */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&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 {
// Check authentication and user setting
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
const content = stripHtml(rawContent)
const content = stripHtmlToPlainText(rawContent)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
return NextResponse.json({ suggestions: [] })
}
const config = await getSystemConfig()

View File

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

View File

@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
export const dynamic = 'force-dynamic';
@@ -23,56 +26,53 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
: (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
const sub = await stripe.subscriptions.retrieve(subId);
const syncUserId =
(checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
userId;
if (syncUserId === userId) {
await syncSubscriptionFromStripe(sub, userId);
}
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
}
}
try {
let subscription = await prisma.subscription.findUnique({ where: { userId } });
const stripeSecret = process.env.STRIPE_SECRET_KEY;
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
if (
subscription?.stripeSubscriptionId
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
&& canTalkToStripe
) {
try {
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
await syncSubscriptionFromStripe(live, userId);
subscription = await prisma.subscription.findUnique({ where: { userId } });
} catch (syncErr) {
console.error('[billing/status] live Stripe period sync failed:', syncErr);
}
} else if (subscription && !subscription.cancelAtPeriodEnd) {
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
if (periodsDiffer(rolled, subscription)) {
subscription = await prisma.subscription.update({
where: { userId },
data: {
currentPeriodStart: rolled.currentPeriodStart,
currentPeriodEnd: rolled.currentPeriodEnd,
},
});
}
}
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
@@ -112,14 +112,19 @@ export async function GET(req: NextRequest) {
}
}
const trialEligible = await shouldOfferSubscriptionTrial(userId);
return NextResponse.json({
tier,
effectiveTier,
status,
currentPeriodEnd: currentPeriodEnd ?? null,
currentPeriodStart: subscription?.currentPeriodStart ?? null,
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices,
creditPacks,
billingEnabled,

View File

@@ -12,6 +12,8 @@ import {
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
break;
}
case 'customer.subscription.trial_will_end': {
const subscription = event.data.object as Stripe.Subscription;
const userId = await resolveUserIdFromStripeEvent(subscription);
if (!userId) {
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
break;
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
name: true,
aiSettings: { select: { preferredLanguage: true } },
},
});
if (!user?.email) break;
await syncSubscriptionFromStripe(subscription, userId);
const trialEndsAt = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
const appUrl =
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
'https://memento-note.com';
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
const mailResult = await sendTrialEndingReminder({
to: user.email,
name: user.name,
trialEndsAt,
billingUrl: `${appUrl}/settings/billing`,
locale: preferred === 'auto' ? 'en' : preferred,
});
if (!mailResult.success) {
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
}
break;
}
default:
break;
}

View File

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

View File

@@ -22,7 +22,7 @@ export async function GET() {
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
try {
const cached = await redis.get(cacheKey)
@@ -39,7 +39,7 @@ export async function GET() {
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
select: { id: true, title: true, content: true, notebookId: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
@@ -64,10 +64,14 @@ export async function GET() {
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const voice = locale === 'fr'
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
${voice}
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
const relatedNotes = recentNotes.slice(0, 2).map(n => {
const title = n.title?.trim()
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return {
id: n.id,
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}` : plain) : null),
notebookId: n.notebookId,
}
})
const payload = { available: true, ...parsed, relatedNotes }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {

View File

@@ -5,8 +5,12 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
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 { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
const MODERATION_TIMEOUT_MS = 12_000
@@ -88,18 +92,69 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
}
}
/** All human-facing text of a PageSpecV1 — fed to moderation. */
function collectPageText(page: PageSpecV1): string[] {
const out: string[] = [
page.hero.kicker,
page.hero.title,
page.hero.subtitle ?? '',
page.hero.meta ?? '',
page.footer ?? '',
]
if (page.overview) {
out.push(page.overview.lead)
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
}
for (const section of page.sections) {
out.push(section.title)
for (const b of section.blocks) {
if (b.type === 'prose') out.push(b.md)
else if (b.type === 'formula') out.push(b.caption ?? '')
else if (b.type === 'callout') out.push(b.title, b.md)
else if (b.type === 'demo') {
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
for (const act of b.demo.acts) {
out.push(act.title)
for (const st of act.steps) out.push(st.speak)
}
for (const panel of b.demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) out.push(n.label ?? '')
}
}
} else if (b.type === 'chart') {
out.push(b.caption ?? '')
for (const s of b.payload.series) out.push(s.label ?? '')
} else if (b.type === 'stats') {
for (const it of b.items) out.push(it.value, it.label)
} else if (b.type === 'table') {
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
} else if (b.type === 'image') {
out.push(b.alt, b.caption ?? '')
} else if (b.type === 'sim') {
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
}
}
}
return out.filter((s) => s && s.trim())
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const { noteId, action, mode, template, language, rewrite } = body as {
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
mode?: 'simple' | 'ai'
mode?: 'simple' | 'ai' | 'interactive-page'
template?: string
language?: string
rewrite?: boolean
pageSpec?: unknown
}
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
@@ -111,13 +166,98 @@ export async function POST(request: NextRequest) {
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') {
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
let validatedPage: PageValidationResult | null = null
if (pageSpec) {
// Client-provided page (from the preview dialog): must validate as-is.
// Never silently substitute a different page than the one previewed.
const checked = validateInteractivePage(pageSpec)
if (!checked.ok) {
return NextResponse.json(
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
{ status: 422 }
)
}
validatedPage = checked
}
if (!validatedPage) {
// Deterministic generate (no LLM quota)
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
const generated = await generateInteractivePageFromContent({
content: note.content || '',
lang: language || 'fr',
provider,
})
if (!generated.ok) {
return NextResponse.json(
{
error: generated.error || 'interactive_page_generation_failed',
reason: generated.reason,
issues: generated.issues?.slice(0, 12),
},
{ status: 422 }
)
}
validatedPage = { ok: true as const, page: generated.page }
}
// Moderate ALL human-facing text of the page (prose, callouts, demo
// narration, tables, captions) — not just titles.
const textForModeration = collectPageText(validatedPage.page).join('\n')
const moderation = await moderateWithFallback(
note.title || '',
textForModeration
)
if (moderation.verdict === 'blocked') {
return NextResponse.json(
{
error: 'blocked',
reason: moderation.reason,
categories: moderation.categories,
},
{ status: 403 }
)
}
if (moderation.verdict === 'flagged') {
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
}
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
const sourceHash = computePublishedSourceHash(note.content || '')
await updateNotePublishState(noteId, {
isPublic: true,
publicSlug: slug,
publishedAt: new Date(),
publishedContent: JSON.stringify(validatedPage.page),
publishedTemplate: 'interactive-page',
publishedSourceHash: sourceHash,
})
return NextResponse.json({
success: true,
slug,
mode: 'interactive-page',
template: 'interactive-page',
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
})
}
const publishMode = mode === 'ai' ? 'ai' : 'simple'
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
if (!template || !isPublishTemplateId(template)) {
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
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 { auth } from '@/auth';
import { getEffectiveTier } from '@/lib/entitlements';
import { isByokProviderAllowed } from '@/lib/byok';
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
import { decryptApiKey } from '@/lib/crypto';
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
// Providers that return static suggestions regardless of key
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
/**
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
*
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
* Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
* Pas de liste de secours figée.
*/
export async function GET(request: NextRequest) {
const session = await auth();
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const provider = searchParams.get('provider') as AiGatewayProvider;
const apiKey = searchParams.get('key') ?? '';
const baseUrl = searchParams.get('baseUrl') ?? undefined;
let apiKey = searchParams.get('key') ?? '';
let baseUrl = searchParams.get('baseUrl') ?? undefined;
if (!provider) {
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
@@ -42,16 +40,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
}
// Static suggestion providers: return immediately without a key
if (STATIC_PROVIDERS.has(provider)) {
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
return NextResponse.json({ success: true, models, fromApi: false });
if (!apiKey || apiKey.length < 4) {
const saved = await getActiveByokKey(session.user.id, provider, tier);
if (saved) {
try {
apiKey = await decryptApiKey(saved.encryptedKey);
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
} catch {
apiKey = '';
}
}
}
// Live providers need a key
if (!apiKey || apiKey.length < 4) {
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
return NextResponse.json({ success: true, models: [], fromApi: false });
}
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);

View File

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

View File

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

View File

@@ -55,6 +55,16 @@ const typeConfig: Record<string, { icon: typeof Globe }> = {
'task-extractor': { icon: ListChecks },
}
function kebabToCamel(value: string) {
return value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
}
function resolveStoredLabel(t: (key: string) => string, value: string) {
if (!value.startsWith('agents.')) return value
const translated = t(value)
return translated !== value ? translated : value
}
const frequencyKeys: Record<string, string> = {
manual: 'agents.frequencies.manual',
hourly: 'agents.frequencies.hourly',
@@ -209,9 +219,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<Icon className="w-5 h-5" />
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-foreground">{agent.name}</h4>
<h4 className="text-[13px] font-bold text-foreground">{resolveStoredLabel(t, agent.name)}</h4>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground opacity-60">
{t(`agents.types.${agent.type || 'custom'}`)}
{t(`agents.types.${kebabToCamel(agent.type || 'custom')}`)}
</p>
</div>
</div>
@@ -237,7 +247,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
{agent.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{agent.description}
{resolveStoredLabel(t, agent.description)}
</p>
)}

View File

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

View File

@@ -16,26 +16,16 @@ import {
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
// ─── Provider display info ────────────────────────────────────────────────────
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'Custom API', hint: 'Your own endpoint' },
}
import { providerDisplayName } from '@/lib/ai/provider-labels'
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
function displayName(provider: string): string {
return PROVIDER_INFO[provider]?.name ?? provider
return providerDisplayName(provider)
}
function providerHint(provider: string): string {
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []
return models.slice(0, 2).join(', ')
}
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
@@ -155,20 +145,20 @@ function EditKeyForm({
const showModelInput = manualModel || (!loadingModels && models.length === 0)
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
<div className="mt-2 border border-brand-accent/20 rounded-xl bg-brand-accent/5 p-4 space-y-3">
<p className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')}</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
@@ -176,7 +166,7 @@ function EditKeyForm({
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
@@ -189,7 +179,7 @@ function EditKeyForm({
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label>
<Input
@@ -205,7 +195,7 @@ function EditKeyForm({
{/* Test result */}
{testResult && (
<div className={cn(
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
'flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] border',
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
)}>
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
@@ -224,7 +214,7 @@ function EditKeyForm({
type="button"
disabled={!newKey || newKey.length < 8 || testing}
onClick={testModel}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-brand-accent/50 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
{t('byok.test')}
@@ -233,7 +223,7 @@ function EditKeyForm({
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
className="flex-1 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.12em] bg-brand-accent text-white shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button>
@@ -286,13 +276,6 @@ export function ByokSettingsPanel() {
setTestResult(null)
setModel('')
setModels([])
if (MANUAL_MODEL_PROVIDERS.has(p)) return
setLoadingModels(true)
try {
const list = await fetchModelsFromServer(p)
setModels(list)
if (list.length > 0) setModel(list[0])
} finally { setLoadingModels(false) }
}
async function refreshModels(p: string, key: string, _baseUrl?: string) {
@@ -403,10 +386,10 @@ export function ByokSettingsPanel() {
{/* Header */}
<div className="flex items-start gap-5">
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20"><KeyRound size={18} /></div>
<div className="flex-1 space-y-1">
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
<p className="text-sm text-concrete leading-relaxed">{t('byokSettings.description')}</p>
</div>
</div>
@@ -419,7 +402,7 @@ export function ByokSettingsPanel() {
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[13px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)}
@@ -433,7 +416,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">{t('byok.savedKeys')}</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
@@ -447,9 +430,9 @@ export function ByokSettingsPanel() {
<div>
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.model && <code className="text-[13px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[13px] text-concrete">{key.alias}</span>}
<span className={cn('text-[13px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span>
</div>
@@ -462,7 +445,7 @@ export function ByokSettingsPanel() {
className={cn(
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
editingProvider === key.provider
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
? 'text-brand-accent bg-brand-accent/10 border border-brand-accent/30'
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
)}
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
@@ -492,7 +475,7 @@ export function ByokSettingsPanel() {
{/* Inline edit form */}
{editingProvider === key.provider && (
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
<div className="border border-brand-accent/20 border-t-0 rounded-b-xl overflow-hidden">
<EditKeyForm
savedKey={key}
onDone={() => setEditingProvider(null)}
@@ -508,40 +491,40 @@ export function ByokSettingsPanel() {
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Label htmlFor="byok-provider" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byokSettings.provider')}</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
<span className="font-medium">{displayName(p)}</span>
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
{providerHint(p) && <span className="ml-2 text-[13px] text-concrete">{providerHint(p)}</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Label htmlFor="byok-alias" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Label htmlFor="byok-baseurl" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
<Label htmlFor="byok-key" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -554,8 +537,8 @@ export function ByokSettingsPanel() {
</div>
<button
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
className={cn('shrink-0 px-4 py-2 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-brand-accent/50')}
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button>
@@ -564,7 +547,7 @@ export function ByokSettingsPanel() {
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
<Label htmlFor="byok-model" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
) : showModelDropdown ? (
@@ -593,13 +576,13 @@ export function ByokSettingsPanel() {
<div className="flex gap-2">
<button
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-brand-accent/50 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
className="flex-1 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.15em] transition-all duration-200 bg-brand-accent text-white shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}

View File

@@ -9,6 +9,8 @@ import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
@@ -23,6 +25,7 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
const { t } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
const fresh = await getNoteById(note.id)
@@ -50,17 +53,24 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
}
}, [t])
const handleDeleteNote = useCallback(async (note: Note) => {
const handleDeleteNote = useCallback((note: Note) => {
setNotePendingDelete(note)
}, [])
const confirmDeleteNote = useCallback(async () => {
const note = notePendingDelete
if (!note) return
setNotePendingDelete(null)
setNotes((prev) => prev.filter((n) => n.id !== note.id))
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée')
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
} catch {
setNotes((prev) => [note, ...prev])
toast.error(t('general.error'))
}
}, [t])
}, [notePendingDelete, t])
if (editingNote) {
return (
@@ -78,11 +88,20 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
}
return (
<>
<NotesEditorialView
notes={notes}
onOpen={handleOpen}
onArchiveNote={handleArchiveNote}
onDeleteNote={handleDeleteNote}
/>
<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 motionProps = item.pulse && !prefersReducedMotion
? {
key: `inbox-pulse-${inboxPulse}`,
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.key}
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
type="button"
onClick={item.onClick}
{...motionProps}
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[128px] ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
@@ -114,7 +113,7 @@ export function DashboardActionStrip({
}`}>
{item.value}
</p>
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
{item.label}
</p>
</div>

View File

@@ -1,8 +1,8 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2, Check } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -14,6 +14,11 @@ export interface AgentSuggestion {
suggestedFrequency: string
}
export interface CreatedAgentNotice {
id: string | null
topic: string
}
export interface DashboardAgentCarouselProps {
suggestions: AgentSuggestion[]
loading?: boolean
@@ -21,6 +26,9 @@ export interface DashboardAgentCarouselProps {
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
createdAgent?: CreatedAgentNotice | null
onOpenCreated?: () => void
onClearCreated?: () => void
prefersReducedMotion?: boolean
}
@@ -31,29 +39,46 @@ export function DashboardAgentCarousel({
formatFrequency,
onAccept,
onDismiss,
createdAgent,
onOpenCreated,
onClearCreated,
prefersReducedMotion,
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const directionRef = useRef(1)
const safeIdx = suggestions.length === 0 ? 0 : Math.min(idx, suggestions.length - 1)
const navActions = suggestions.length > 1 ? (
const goPrev = () => {
directionRef.current = -1
setIdx(i => Math.max(0, i - 1))
}
const goNext = () => {
directionRef.current = 1
setIdx(i => Math.min(suggestions.length - 1, i + 1))
}
const showCreated = !!createdAgent
const showNav = !showCreated && suggestions.length > 1
const navActions = showNav ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
disabled={idx === 0}
onClick={goPrev}
disabled={safeIdx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={12} />
</button>
<span className="text-[8px] font-mono text-concrete px-1">
{idx + 1}/{suggestions.length}
{safeIdx + 1}/{suggestions.length}
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
disabled={idx >= suggestions.length - 1}
onClick={goNext}
disabled={safeIdx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
>
@@ -73,9 +98,18 @@ export function DashboardAgentCarousel({
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.suggestedResearch')}
actions={navActions}
wrapTitle
/>
{suggestions.length === 0 ? (
{showCreated && createdAgent ? (
<CreatedAgentPanel
topic={createdAgent.topic}
hasMore={suggestions.length > 0}
onOpen={onOpenCreated}
onSeeNext={onClearCreated}
t={t}
/>
) : suggestions.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] text-concrete leading-relaxed">
{t('homeDashboard.agentsEmpty')}
@@ -83,7 +117,8 @@ export function DashboardAgentCarousel({
</div>
) : (
<AgentSlide
current={suggestions[idx]}
current={suggestions[safeIdx]}
direction={directionRef.current}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
@@ -96,8 +131,57 @@ export function DashboardAgentCarousel({
)
}
function CreatedAgentPanel({
topic,
hasMore,
onOpen,
onSeeNext,
t,
}: {
topic: string
hasMore: boolean
onOpen?: () => void
onSeeNext?: () => void
t: (key: string) => string
}) {
return (
<div className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40">
<div className="flex items-start gap-2 mb-2">
<Check size={14} className="text-brand-accent shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">
{t('homeDashboard.agentCreatedInCard')}
</p>
{topic ? (
<p className="text-[11px] text-concrete leading-relaxed mt-1">{topic}</p>
) : null}
</div>
</div>
<div className="flex gap-2 mt-3">
{hasMore && onSeeNext ? (
<button
type="button"
onClick={onSeeNext}
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors"
>
{t('homeDashboard.agentSeeNextSuggestion')}
</button>
) : null}
<button
type="button"
onClick={onOpen}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90"
>
{t('homeDashboard.agentOpenCreated')}
</button>
</div>
</div>
)
}
function AgentSlide({
current,
direction,
actingId,
formatFrequency,
onAccept,
@@ -106,6 +190,7 @@ function AgentSlide({
t,
}: {
current: AgentSuggestion
direction: number
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
@@ -115,7 +200,11 @@ function AgentSlide({
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
: {
initial: { opacity: 0, x: 14 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -14 * direction },
}
return (
<AnimatePresence mode="wait">
@@ -148,7 +237,7 @@ function AgentSlide({
type="button"
disabled={actingId === current.id}
onClick={() => onAccept(current.id)}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90 disabled:opacity-40"
>
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
{t('homeDashboard.createAgent')}

View File

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

View File

@@ -19,12 +19,22 @@ interface BridgeNote {
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const EASE = [0.16, 1, 0.3, 1] as const
function clusterPoint(index: number, count: number): { x: number; y: number } {
if (count === 1) return { x: 50, y: 42 }
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
const rx = count <= 3 ? 28 : 36
const ry = count <= 3 ? 24 : 30
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
}
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onOpenCluster?: (clusterId: number) => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
bridgeNotes,
loading,
onOpenInsights,
onOpenCluster,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
const reduced = !!prefersReducedMotion
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
@@ -80,21 +93,60 @@ export function DashboardMindOrbit({
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
<motion.div className="relative h-[200px] overflow-visible">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden
>
{points.map((point, idx) => {
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
return (
<motion.path
key={`spoke-${topClusters[idx].clusterId}`}
d={`M 50 44 L ${point.x} ${point.y}`}
fill="none"
stroke={color}
strokeWidth={0.7}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 0.4 }}
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
/>
)
})}
</svg>
<div
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
aria-hidden
/>
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
const size = Math.round(52 * scale)
const label = cluster.name?.trim() || t('homeDashboard.theme')
const point = points[idx]
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
whileHover={reduced ? undefined : { scale: 1.06 }}
whileTap={reduced ? undefined : { scale: 0.97 }}
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
aria-label={label}
style={{
left: `${point.x}%`,
top: `${point.y}%`,
width: Math.max(size + 24, 128),
}}
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
@@ -108,19 +160,25 @@ export function DashboardMindOrbit({
>
{cluster.noteIds.length}
</div>
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
<span className="text-[9px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
{label}
</span>
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[10px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
{label}
</span>
</motion.button>
)
})}
</div>
</motion.div>
{topBridge?.note && (
<button
<motion.button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
@@ -132,7 +190,7 @@ export function DashboardMindOrbit({
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
</motion.button>
)}
</div>
)

View File

@@ -12,16 +12,16 @@ import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
continue: { Icon: PenLine, accent: 'text-ink' },
continue: { Icon: PenLine, accent: 'text-brand-accent' },
connect: { Icon: Link2, accent: 'text-brand-accent' },
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
research: { Icon: Sparkles, accent: 'text-sky-600' },
explore: { Icon: Compass, accent: 'text-amber-600' },
'add-link': { Icon: Plus, accent: 'text-brand-accent' },
bridge: { Icon: GitBranch, accent: 'text-brand-accent' },
research: { Icon: Sparkles, accent: 'text-brand-accent' },
explore: { Icon: Compass, accent: 'text-brand-accent' },
organize: { Icon: Inbox, accent: 'text-brand-accent' },
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
daily: { Icon: BookOpen, accent: 'text-concrete' },
daily: { Icon: BookOpen, accent: 'text-brand-accent' },
}
export interface DashboardNextPathsProps {
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const rtl = language === 'ar' || language === 'fa'
if (loading) {
return (
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
const reduced = !!prefersReducedMotion
const ease = [0.16, 1, 0.3, 1] as const
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
whileHover={reduced ? undefined : { y: -1 }}
key={hero.id}
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.48, ease }}
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
>
<div className="flex items-start gap-3">
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
{rest.map((path, idx) => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
<motion.button
key={path.id}
type="button"
onClick={() => onAction(path)}
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
>
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
</motion.button>
)
})}
</div>

View File

@@ -153,7 +153,7 @@ export function DashboardLinkSuggestions({
return (
<DashboardWidgetShell
widgetId="link-suggestions"
icon={<Circle size={12} className="text-emerald-600" />}
icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.link-suggestions')}
>
{loading ? (
@@ -169,16 +169,16 @@ export function DashboardLinkSuggestions({
key={p.id}
type="button"
onClick={() => onAction(p.id)}
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
{p.score ? (
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
<span className="text-[8px] font-mono text-brand-accent shrink-0">{p.score}%</span>
) : null}
</div>
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} </p>
<p className="text-[8px] font-mono uppercase text-brand-accent mt-2">{t('homeDashboard.pathActions.addLink')} </p>
</button>
))}
</div>
@@ -212,7 +212,7 @@ export function DashboardBridgesWidget({
return (
<DashboardWidgetShell
widgetId="bridges"
icon={<Circle size={12} className="text-violet-600" />}
icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.bridges')}
>
{loading ? (

View File

@@ -1,6 +1,5 @@
'use client'
import { motion } from 'motion/react'
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -24,27 +23,33 @@ export interface DashboardResumeHeroProps {
prefersReducedMotion?: boolean
}
function resumeDisplayTitle(note: ResumeNote, fallback: string): string {
const titled = note.title?.trim()
if (titled) return titled
const excerpt = note.excerpt?.trim()
if (!excerpt) return fallback
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}` : excerpt
}
export function DashboardResumeHero({
notes,
loading,
onSelect,
onCaptureFocus,
formatRelativeTime,
prefersReducedMotion,
}: DashboardResumeHeroProps) {
const { t } = useLanguage()
const hero = notes[0]
const rest = notes.slice(1, 6)
const untitled = t('homeDashboard.untitled')
const visible = notes.slice(0, 6)
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[180px] animate-pulse">
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
<div className="space-y-2">
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
</div>
</div>
)
@@ -61,7 +66,7 @@ export function DashboardResumeHero({
/>
</div>
{!hero ? (
{visible.length === 0 ? (
<div className="px-5 pb-5">
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
@@ -81,46 +86,8 @@ export function DashboardResumeHero({
</div>
</div>
) : (
<>
<motion.button
type="button"
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => onSelect(hero.id, hero.notebookId)}
className="w-full text-start px-5 pb-3 group cursor-pointer"
>
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-3 mb-2">
<span
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
style={{ backgroundColor: hero.notebookColor }}
>
{hero.notebookName}
</span>
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
{formatRelativeTime(hero.updatedAt)}
</span>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
{hero.title || t('homeDashboard.untitled')}
</h3>
{hero.excerpt ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
</p>
) : null}
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
{t('homeDashboard.resumeOpen')}
<ChevronRight size={12} />
</div>
</div>
</motion.button>
{rest.length > 0 && (
<div className="px-5 pb-4 space-y-1.5">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
{t('homeDashboard.resumeAlso')}
</p>
{rest.map(note => (
{visible.map(note => (
<button
key={note.id}
type="button"
@@ -134,7 +101,7 @@ export function DashboardResumeHero({
/>
<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')}
{resumeDisplayTitle(note, untitled)}
</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{note.notebookName}
@@ -147,8 +114,6 @@ export function DashboardResumeHero({
))}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -10,6 +10,12 @@ interface EmotionMeta {
color: string
}
export interface SentimentRelatedNote {
id: string
title: string | null
notebookId: string | null
}
export interface DashboardSentimentChipProps {
available: boolean
loading?: boolean
@@ -17,6 +23,8 @@ export interface DashboardSentimentChipProps {
summary?: string
emotions?: Record<string, number>
emotionMeta: Record<string, EmotionMeta>
relatedNotes?: SentimentRelatedNote[]
onSelectNote?: (id: string, notebookId: string | null) => void
}
export function DashboardSentimentChip({
@@ -26,6 +34,8 @@ export function DashboardSentimentChip({
summary,
emotions,
emotionMeta,
relatedNotes = [],
onSelectNote,
}: DashboardSentimentChipProps) {
const { t } = useLanguage()
@@ -80,6 +90,24 @@ export function DashboardSentimentChip({
</p>
)}
{relatedNotes.length > 0 && onSelectNote && (
<div className="space-y-1 pt-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70">
{t('homeDashboard.sentimentFromNotes')}
</p>
{relatedNotes.map(note => (
<button
key={note.id}
type="button"
onClick={() => onSelectNote(note.id, note.notebookId)}
className="w-full text-start text-[11px] text-ink dark:text-dark-ink truncate px-2 py-1.5 rounded-lg hover:bg-brand-accent/[0.04] hover:text-brand-accent transition-colors"
>
{note.title?.trim() || t('homeDashboard.untitled')}
</button>
))}
</div>
)}
{emotions && (
<div className="space-y-2 pt-1">
{Object.entries(emotions)

View File

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

View File

@@ -40,6 +40,7 @@ import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
}
function SortableWidget({
@@ -139,7 +140,7 @@ function ZoneColumn({
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
@@ -228,15 +229,30 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const mainWidgets = visibleWidgetsInZone(layout, 'main')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const sideWidgets = visibleWidgetsInZone(layout, 'side')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
return (
<>
<div className="space-y-4">
<div className={editMode ? 'space-y-4 pb-24' : 'space-y-4'}>
{!editMode && loaded && hasVisibleWidgets && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setEditMode(true)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-1.5 rounded-xl text-brand-accent hover:bg-brand-accent/10 transition-colors"
>
<LayoutGrid size={12} />
{t('homeDashboard.widgetCustomize')}
</button>
</div>
)}
{loaded ? (
hasVisibleWidgets ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
@@ -307,9 +323,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
)}
</div>
{editMode && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
{editMode ? (
<>
<button
type="button"
onClick={() => setCatalogOpen(v => !v)}
@@ -337,18 +352,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
<Check size={12} />
{t('homeDashboard.widgetDone')}
</button>
</>
) : (
<button
type="button"
onClick={() => setEditMode(true)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<LayoutGrid size={12} />
{t('homeDashboard.widgetCustomize')}
</button>
)}
</div>
)}
{editMode && catalogOpen && (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">

View File

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

View File

@@ -12,6 +12,8 @@ interface HeatmapDay {
interface RevisionHeatmapProps {
data: HeatmapDay[]
className?: string
/** Révisions de flashcards, ou notes modifiées sur le tableau de bord. */
kind?: 'reviews' | 'edits'
}
function intensityClass(count: number, max: number): string {
@@ -28,17 +30,23 @@ function resolveDateLocale(langCode: string): string {
return langCode
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
export function RevisionHeatmap({ data, className, kind = 'reviews' }: RevisionHeatmapProps) {
const { t, language } = useLanguage()
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
const dateLocale = resolveDateLocale(language ?? 'en')
const prefix = kind === 'edits' ? 'homeDashboard.activityHeatmap' : 'flashcards.heatmap'
const dayLabel = (count: number) => {
if (count <= 0) return t(`${prefix}DayNone`)
if (count === 1) return t(`${prefix}DayOne`)
return t(`${prefix}Day`, { count })
}
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count]))
const now = new Date()
// todayUTC est le début de la journée courante à minuit UTC
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
const cells: { date: string; count: number; label: string }[] = []
@@ -78,22 +86,22 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
return (
<div className={cn('space-y-2', className)}>
{/* En-tête */}
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t('flashcards.heatmapTitle')}
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
{t(`${prefix}Title`)}
</p>
<span className="text-[10px] text-concrete/60">
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
<span className="text-[13px] text-concrete/70">
{totalReviews > 0
? t(`${prefix}Total`, { count: totalReviews })
: t(kind === 'edits' ? 'homeDashboard.activityHeatmapLast90' : 'flashcards.heatmapLast90')}
</span>
</div>
{/* Labels de mois au-dessus de la grille */}
<div className="relative h-4">
<div className="relative h-6">
{monthLabels.map((m) => (
<span
key={m.label + m.index}
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
className="absolute text-[13px] text-concrete/70 font-medium leading-tight"
style={{ left: pct(m.index) }}
>
{m.label}
@@ -101,14 +109,11 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
))}
</div>
{/* Grille pleine largeur */}
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => {
const isHovered = hovered?.date === cell.date
const isSelected = selected?.date === cell.date
const reviewText = cell.count > 0
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
: 'Aucune révision'
const reviewText = dayLabel(cell.count)
return (
<button
@@ -134,47 +139,29 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
})}
</div>
{/* Info au survol / clic */}
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
<div className="min-h-7 flex items-center text-[13px] border-b border-border/20 pb-1">
{activeInfo ? (
<p className="flex items-center gap-1.5 animate-fadeIn">
<span className="font-semibold text-foreground">
{activeInfo.count > 0
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
: 'Aucune révision'}
{dayLabel(activeInfo.count)}
</span>
<span className="text-concrete">· {activeInfo.label}</span>
{selected?.date === activeInfo.date && !hovered && (
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
sélectionné
</span>
)}
</p>
) : (
<p className="text-[10px] text-concrete/40 italic">
Survolez ou cliquez sur un carré pour voir le détail
<p className="text-[13px] text-concrete/50 italic">
{t(`${prefix}Hint`)}
</p>
)}
{selected && (
<button
type="button"
onClick={() => setSelected(null)}
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
>
Effacer la sélection
</button>
)}
</div>
{/* Légende */}
<div className="flex items-center gap-2 pt-0.5">
<span className="text-[9px] text-concrete/50">Moins</span>
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapLess')}</span>
<div className="flex gap-0.5">
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
))}
</div>
<span className="text-[9px] text-concrete/50">Plus</span>
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapMore')}</span>
</div>
</div>
)

View File

@@ -35,6 +35,8 @@ import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -160,6 +162,7 @@ export function HomeClient({
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false)
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
const handleExportCSV = useCallback(() => {
if (!searchParams.get('notebook')) return
@@ -318,6 +321,8 @@ export function HomeClient({
}
setNotes((prevNotes) => {
if (prevNotes.some((n) => n.id === note.id)) return prevNotes
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
@@ -533,21 +538,25 @@ export function HomeClient({
[patchNoteInList, t]
)
const handleDeleteNoteFromList = useCallback(
async (note: Note) => {
const handleDeleteNoteFromList = useCallback((note: Note) => {
setNotePendingDelete(note)
}, [])
const confirmDeleteNoteFromList = useCallback(async () => {
const note = notePendingDelete
if (!note) return
setNotePendingDelete(null)
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
try {
await deleteNote(note.id, { skipRevalidation: true })
toast.success(t('notes.deleted') || 'Note supprimée')
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
} catch {
setNotes((prev) => [note, ...prev])
emitNoteChange({ type: 'created', note })
toast.error(t('general.error'))
}
},
[removeNoteFromList, t]
)
}, [notePendingDelete, removeNoteFromList, t])
const handleArchiveNoteFromList = useCallback(
async (note: Note) => {
@@ -630,12 +639,16 @@ export function HomeClient({
removeNoteFromList(detail.noteId)
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
} else if (detail.type === 'created' && detail.note) {
setNotes(prev => [detail.note, ...prev])
setNotes((prev) => {
if (prev.some((n) => n.id === detail.note.id)) return prev
if (filterNotesForCurrentView([detail.note]).length === 0) return prev
return [detail.note, ...prev]
})
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [patchNoteInList])
}, [patchNoteInList, filterNotesForCurrentView])
const handleGridReorder = useCallback(
async (orderedIds: string[]) => {
@@ -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.)
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
if (!openNoteId) {
setEditingNote(null)
return
}
let cancelled = false
const run = async () => {
@@ -784,7 +800,12 @@ export function HomeClient({
// Apply sort order to notes
const sortedNotes = useMemo(() => {
let sorted = [...notes]
const seen = new Set<string>()
let sorted = notes.filter((n) => {
if (seen.has(n.id)) return false
seen.add(n.id)
return true
})
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
@@ -816,8 +837,15 @@ export function HomeClient({
const handleEditorClose = useCallback(() => {
setEditingNote(null)
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) {
router.replace('/home', { scroll: false })
return
}
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
params.delete('peekNote')
params.delete('from')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
@@ -831,10 +859,17 @@ export function HomeClient({
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const handleDashboardNoteSelect = useCallback((
noteId: string,
notebookId: string | null,
peekNoteId?: string | null,
) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
params.set('from', 'dashboard')
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
router.push(`/home?${params.toString()}`)
}, [router])
@@ -1463,6 +1498,14 @@ export function HomeClient({
/>
)}
<ConfirmDeleteNoteDialog
open={notePendingDelete != null}
onOpenChange={(open) => {
if (!open) setNotePendingDelete(null)
}}
onConfirm={confirmDeleteNoteFromList}
/>
{showNotebookSlides && currentNotebook && (
<NotebookSlidesDialog
notebookId={currentNotebook.id}

View File

@@ -10,6 +10,7 @@ import {
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
import { pathNoteTitle } from '@/lib/dashboard/path-title'
// ─── 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"
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}
</p>
</div>
<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)` }} />
<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` }}
>
{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"
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}
</p>
</div>
@@ -149,10 +150,11 @@ export interface IntelligenceHubProps {
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (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
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
excludeBridgeSuggestionKeys?: string[]
}
// ─── Component ─────────────────────────────────────────
@@ -178,6 +180,7 @@ export function IntelligenceHub({
dismissingInsightId,
actingBridgeSuggestionKey,
prefersReducedMotion,
excludeBridgeSuggestionKeys,
}: IntelligenceHubProps) {
const router = useRouter()
const { t } = useLanguage()
@@ -226,10 +229,13 @@ export function IntelligenceHub({
})
}
const hiddenSuggestions = new Set(excludeBridgeSuggestionKeys ?? [])
for (const suggestion of bridgeSuggestions) {
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
if (hiddenSuggestions.has(key)) continue
items.push({
kind: 'suggestion',
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
id: `suggestion-${key}`,
priority: 80,
suggestion,
})
@@ -245,7 +251,7 @@ export function IntelligenceHub({
}
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
}, [insights, bridgeNotes, bridgeSuggestions, agentActions, excludeBridgeSuggestionKeys])
const counts = useMemo(() => ({
all: allItems.length,
@@ -302,40 +308,40 @@ export function IntelligenceHub({
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={12} className="text-indigo-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
<Sparkles size={12} className="text-brand-accent" />
<span className="text-[13px] font-medium text-brand-accent">
{t('homeDashboard.semanticConnection')}
</span>
{!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>
<ConnectionDiagram
note1Title={insight.note1.title || t('homeDashboard.untitled')}
note2Title={insight.note2.title || t('homeDashboard.untitled')}
note1Title={pathNoteTitle(insight.note1.title, insight.note1Excerpt) || t('homeDashboard.untitled')}
note2Title={pathNoteTitle(insight.note2.title, insight.note2Excerpt) || t('homeDashboard.untitled')}
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} »
</p>
{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}
</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
type="button"
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} />
{t('homeDashboard.intelOpenNote')}
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, 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"
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
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} />
{t('homeDashboard.intelCompare')}
@@ -358,19 +364,19 @@ export function IntelligenceHub({
case 'bridge': {
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 names = bridge.clusterNames ?? []
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<Zap size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
<Zap size={12} className="text-brand-accent" />
<span className="text-[13px] font-medium text-brand-accent">
{t('homeDashboard.bridgeNote')}
</span>
</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)}%
</span>
</div>
@@ -379,11 +385,11 @@ export function IntelligenceHub({
onClick={() => onNoteSelect(bridge.noteId)}
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}
</p>
{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 && (
<div className="flex flex-wrap gap-1.5">
@@ -396,7 +402,7 @@ export function IntelligenceHub({
className="w-1.5 h-1.5 rounded-full"
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>
))}
</div>
@@ -405,7 +411,7 @@ export function IntelligenceHub({
<button
type="button"
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} />
{t('homeDashboard.intelOpenNote')}
@@ -421,28 +427,28 @@ export function IntelligenceHub({
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Lightbulb size={12} className="text-violet-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
<Lightbulb size={12} className="text-brand-accent" />
<span className="text-[13px] font-medium text-brand-accent truncate">
{t('homeDashboard.intelMissingLink')}
</span>
</div>
<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}
</span>
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
<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-brand-accent/10 text-[13px] font-medium text-brand-accent truncate max-w-[45%]" title={suggestion.clusterBName}>
{suggestion.clusterBName}
</span>
</div>
<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>
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
<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 shrink-0">
<button
type="button"
disabled={busy}
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} />}
{t('homeDashboard.createBridgeNote')}
@@ -451,7 +457,7 @@ export function IntelligenceHub({
type="button"
disabled={busy}
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')}
</button>
@@ -465,25 +471,25 @@ export function IntelligenceHub({
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Bot size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
<Bot size={12} className="text-brand-accent" />
<span className="text-[13px] font-medium text-brand-accent">
{agent.agentName}
</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)}
</span>
</div>
{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}
</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
type="button"
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')}
<ArrowRight size={9} />
@@ -497,10 +503,10 @@ export function IntelligenceHub({
const spotlightAccent = (item: IntelItem | null) => {
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
switch (item.kind) {
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
case 'insight': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'bridge': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'suggestion': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
case 'agent': return 'from-brand-accent/[0.04] via-transparent to-transparent border-border/30'
}
}
@@ -518,7 +524,7 @@ export function IntelligenceHub({
actions={(
<>
{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')}
</span>
)}
@@ -543,7 +549,7 @@ export function IntelligenceHub({
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
) : !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">
<p className="text-xs text-concrete leading-relaxed">
<p className="text-sm text-concrete leading-relaxed">
{!hasAiConsent
? t('homeDashboard.aiConsentRequired')
: !providerReady
@@ -554,7 +560,7 @@ export function IntelligenceHub({
<button
type="button"
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')}
</button>
@@ -563,12 +569,12 @@ export function IntelligenceHub({
) : 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">
<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
type="button"
onClick={onRefreshEcho}
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} />}
{t('homeDashboard.analyzeNotes')}
@@ -587,15 +593,15 @@ export function IntelligenceHub({
key={f.key}
type="button"
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
? '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'
}`}
>
{f.label}
{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}
</span>
)}
@@ -605,7 +611,7 @@ export function IntelligenceHub({
</div>
{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 */}
@@ -616,7 +622,7 @@ export function IntelligenceHub({
type="button"
onClick={goPrev}
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')}
>
<ChevronLeft size={14} />
@@ -625,7 +631,7 @@ export function IntelligenceHub({
type="button"
onClick={goNext}
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')}
>
<ChevronRight size={14} />
@@ -664,14 +670,14 @@ export function IntelligenceHub({
onClick={() => setActiveIndex(idx)}
className={`rounded-full transition-all ${
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'
}`}
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
/>
))}
</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 })}
</span>
</div>
@@ -684,37 +690,39 @@ export function IntelligenceHub({
const isActive = idx === activeIndex
let label = ''
let Icon = Sparkles
let accent = 'text-indigo-500'
let accent = 'text-brand-accent'
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
accent = 'text-indigo-500'
accent = 'text-brand-accent'
} 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
accent = 'text-ochre'
accent = 'text-brand-accent'
} else if (item.kind === 'suggestion') {
label = item.suggestion.suggestedTitle.slice(0, 28)
label = item.suggestion.suggestedTitle
Icon = Lightbulb
accent = 'text-violet-500'
accent = 'text-brand-accent'
} else {
label = item.agent.agentName
Icon = Bot
accent = 'text-ochre'
accent = 'text-brand-accent'
}
return (
<button
key={item.id}
type="button"
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
? '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'
}`}
>
<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>
)
})}

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 { useLanguage } from '@/lib/i18n'
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 { useQuery } from '@tanstack/react-query'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
@@ -38,6 +40,16 @@ export function LandingPage() {
const [langOpen, setLangOpen] = useState(false)
const [echoIndex, setEchoIndex] = useState(0)
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(() => {
if (!langOpen) return
@@ -68,11 +80,30 @@ export function LandingPage() {
return () => { root.style.overflow = prev }
}, [menuOpen])
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
const NAV = [
@@ -82,6 +113,22 @@ export function LandingPage() {
{ 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 (
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
{/* Nav */}
@@ -94,7 +141,16 @@ export function LandingPage() {
</Link>
<div className="hidden lg:flex items-center gap-8">
{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}
</a>
))}
@@ -147,7 +203,7 @@ export function LandingPage() {
</AnimatePresence>
</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')}
</Link>
<Link
@@ -180,13 +236,25 @@ export function LandingPage() {
<a
key={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"
>
{l.label}
</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')}
</Link>
</div>
@@ -217,7 +285,7 @@ export function LandingPage() {
</span>
</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')}
</p>
@@ -227,7 +295,7 @@ export function LandingPage() {
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
</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')}
</p>
@@ -239,7 +307,7 @@ export function LandingPage() {
{t('landing.hero.cta')}
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
</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>
@@ -287,7 +355,7 @@ export function LandingPage() {
{/* Trust strip */}
<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) => (
<span key={k} className="flex items-center gap-2">
<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">
{t('landing.pain.title')}
</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">
{t('landing.pain.secondBrain')}
</p>
@@ -346,7 +414,7 @@ export function LandingPage() {
<div className="grid grid-cols-2 gap-2 p-2">
{['w0', 'w1', 'w2', 'w3'].map((w) => (
<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>
</div>
))}
@@ -363,7 +431,7 @@ export function LandingPage() {
>
<div className="relative h-36 flex items-center justify-center">
<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')}
</p>
</div>
@@ -379,7 +447,7 @@ export function LandingPage() {
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
<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] 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>
</ProductMoment>
@@ -398,7 +466,7 @@ export function LandingPage() {
<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>
<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>
@@ -411,14 +479,14 @@ export function LandingPage() {
<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>
<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 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) => (
<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" />
<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>
@@ -434,15 +502,19 @@ export function LandingPage() {
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
</div>
<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 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">
<p className="text-[#D4A373]">{'{'}</p>
<p className="pl-3">&quot;provider&quot;: &quot;anthropic&quot;,</p>
<p className="pl-3">&quot;model&quot;: &quot;claude-sonnet&quot;,</p>
<p className="pl-3 text-[#F4F1EA]/70">&quot;apiKey&quot;: &quot;sk-ant-&quot;,</p>
<p className="pl-3">&quot;yours&quot;: true</p>
<p className="text-[#D4A373]">{'}'}</p>
<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">
{byokProviders.length > 0 ? (
<ul className="grid grid-cols-1 gap-2 text-sm text-[#F4F1EA]">
{byokProviders.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
) : (
<p className="text-white/70">{t('landing.byok.pointProvider')}</p>
)}
<p className="text-[#D4A373]">{t('landing.byok.pointYours')}</p>
</div>
</div>
</section>
@@ -452,22 +524,24 @@ export function LandingPage() {
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<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]">
<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/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')}
</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/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')}
<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>
</div>
</div>
@@ -486,20 +560,33 @@ export function LandingPage() {
{t('landing.pricing.popular')}
</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`)}
</h4>
<div className="flex items-baseline gap-1 mb-2">
<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>
<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">
{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}`)
const feat = t(`landing.pricing.${plan.key}.feature${j}`, {
count: byokProviders.length || '…',
})
if (!feat || feat.startsWith('landing.')) return null
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" />
{feat}
</li>
@@ -508,13 +595,15 @@ export function LandingPage() {
</ul>
<Link
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
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: '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>
</div>
))}
@@ -532,7 +621,7 @@ export function LandingPage() {
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
{t('landing.cta.title')}
</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
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]"
@@ -540,7 +629,7 @@ export function LandingPage() {
{t('landing.cta.button')}
<ArrowRight size={18} />
</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>
</section>
@@ -553,15 +642,15 @@ export function LandingPage() {
</div>
<span className="font-serif text-lg">Memento</span>
</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 className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((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`)}
</p>
<ul className="space-y-2 text-white/50">
<ul className="space-y-2 text-white/70">
{[0, 1, 2].map((j) => {
const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`)
@@ -581,7 +670,7 @@ export function LandingPage() {
))}
</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')}
</p>
</footer>
@@ -624,7 +713,7 @@ function ProductMoment({
{eyebrow}
</p>
<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 className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
<div className="w-full">{children}</div>

View File

@@ -1,12 +1,15 @@
'use client';
import { useActionState } from 'react';
import { useActionState, useRef, useState, Suspense } from 'react';
import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { GoogleSignInButton } from '@/components/google-sign-in-button';
import { resendSignupVerification } from '@/app/actions/auth-verify';
import { toast } from 'sonner';
function AuthDivider({ label }: { label: string }) {
return (
@@ -41,7 +44,7 @@ function LoginButton() {
);
}
export function LoginForm({
function LoginFormInner({
allowRegister = true,
googleAuthEnabled = false,
authError,
@@ -51,7 +54,11 @@ export function LoginForm({
authError?: string;
}) {
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 =
authError === 'SessionRequired'
? t('auth.sessionExpired')
@@ -59,6 +66,21 @@ export function LoginForm({
? t('auth.oauthAccountNotLinked')
: 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 (
<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">
@@ -71,6 +93,12 @@ export function LoginForm({
</p>
</div>
{verified && (
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
{t('auth.emailVerifiedBanner')}
</p>
)}
{oauthError && (
<p className="text-sm text-red-500 text-center px-2" role="alert">
{oauthError}
@@ -94,6 +122,7 @@ export function LoginForm({
<Mail size={16} />
</div>
<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"
id="email"
type="email"
@@ -136,13 +165,26 @@ export function LoginForm({
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
<div className="space-y-2" aria-live="polite" aria-atomic="true">
{showUnverified && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
{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>
</form>
@@ -164,3 +206,15 @@ export function LoginForm({
</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} />
</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 className="p-6">
<p className="text-[11px] text-concrete leading-relaxed">
<p className="text-sm text-concrete leading-relaxed">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
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')}
<ExternalLink className="h-3 w-3" />
@@ -156,21 +156,21 @@ export function McpSettingsPanel({
<Server size={18} />
</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 className="p-6">
<div className="space-y-4">
<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-[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-sm text-concrete">{t('mcpSettings.serverStatus.mode')}</span>
<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()}
</span>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="space-y-2">
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">{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">
<span className="text-sm text-concrete">{t('mcpSettings.serverStatus.url')}</span>
<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}
</code>
</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="flex items-center justify-between p-6 border-b border-border/40">
<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} />
</div>
<div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div>
</div>
{!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 })}
</p>
) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<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" />
{t('mcpSettings.apiKeys.generate')}
</button>
@@ -209,7 +209,7 @@ export function McpSettingsPanel({
{!mcpAllowed && (
<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')}
</p>
</div>
@@ -219,7 +219,7 @@ export function McpSettingsPanel({
{keys.length === 0 ? (
<div className="text-center py-8">
<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 className="space-y-3">
@@ -243,7 +243,7 @@ export function McpSettingsPanel({
</DialogHeader>
<div className="space-y-3">
<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">
<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}
@@ -260,7 +260,7 @@ export function McpSettingsPanel({
<DialogFooter>
<button
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')}
</button>
@@ -283,7 +283,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
</DialogHeader>
<div className="space-y-3">
<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
id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
@@ -297,7 +297,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
<button
onClick={() => onGenerate(name)}
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">
{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="space-y-1">
<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(
'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
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300'
: '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')}
</span>
</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.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span>
</div>
@@ -341,7 +341,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
<button
onClick={() => onRevoke(keyInfo.shortId)}
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" />
{t('mcpSettings.apiKeys.revoke')}
@@ -350,7 +350,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
<button
onClick={() => onDelete(keyInfo.shortId)}
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" />
{t('mcpSettings.apiKeys.delete')}
@@ -428,8 +428,8 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
<ExternalLink size={18} />
</div>
<div>
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
</div>
</div>
<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"
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 ? (
<ChevronDown className="h-4 w-4 text-concrete" />
) : (
@@ -448,7 +448,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
</button>
{expanded === cfg.id && (
<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">
<pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border">
<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)
.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')
.attr('dy', d => d.radius + 13)
.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 { pl } from 'date-fns/locale/pl'
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 { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
@@ -308,6 +310,7 @@ export const NoteCard = memo(function NoteCard({
await deleteNote(note.id, { skipRevalidation: true })
await refreshLabels()
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
showNoteTrashedToast(note, t, () => setIsHidden(false))
} catch (error) {
console.error('Failed to delete note:', error)
setIsHidden(false)
@@ -869,23 +872,11 @@ export const NoteCard = memo(function NoteCard({
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<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>
<ConfirmDeleteNoteDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
/>
{/* Leave Share Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>

View File

@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter()
const searchParams = useSearchParams()
const peekNoteId = searchParams.get('peekNote')
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
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(() => {
const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}
}, [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(() => {
setPeekState(null)
}, [])
stripPeekFromUrl()
}, [stripPeekFromUrl])
const handleOpenPeekFully = useCallback(() => {
if (!peekState) return
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}))
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id)
params.delete('peekNote')
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null)
}, [noteId, peekState, router, searchParams])
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
blockId={peekState.blockId}
onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully}
onBackToDashboard={
searchParams.get('from') === 'dashboard'
? () => router.replace('/home')
: undefined
}
/>
)}
</AnimatePresence>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef } from 'react'
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 { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
@@ -17,6 +17,7 @@ interface NoteEditorSplitPeekProps {
blockId?: string
onClose: () => void
onOpenFully: () => void
onBackToDashboard?: () => void
}
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 isRtl = language === 'fa' || language === 'ar'
@@ -77,6 +78,16 @@ export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: Not
{t('notePeek.label')}
</span>
<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
type="button"
onClick={onOpenFully}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -19,11 +20,14 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
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'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-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 { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
@@ -47,18 +51,23 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [publishOpen, setPublishOpen] = useState(false)
const [publishLoading, setPublishLoading] = useState(false)
const [interactivePageOpen, setInteractivePageOpen] = useState(false)
const [interactivePageContent, setInteractivePageContent] = useState('')
const [publishMeta, setPublishMeta] = useState({
isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null,
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
})
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
const [publishRewrite, setPublishRewrite] = useState(false)
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
@@ -343,6 +352,33 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
magazine: t('richTextEditor.publishTemplateMagazine'),
brief: t('richTextEditor.publishTemplateBrief'),
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 () => {
@@ -539,7 +575,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<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>
<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')}
</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="flex items-center gap-1.5">
<Sparkles size={13} className="text-brand-accent shrink-0" />
@@ -679,7 +736,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</p>
{/* Sélection template */}
<div className="space-y-1">
{PUBLISH_TEMPLATES.map((tpl) => (
{classicPublishTemplates.map((tpl) => (
<label
key={tpl}
className={cn(
@@ -833,12 +890,12 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
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} />
</div>
<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>
</button>
<button
@@ -965,14 +1022,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
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')) }
}}
onClick={() => setConfirmDeleteOpen(true)}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 me-2" />
@@ -982,6 +1032,19 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</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 && (
<NoteShareDialog
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
aria-label={t('notes.documentInfoAria')}
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 { useNotebooks } from '@/context/notebooks-context'
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 { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
@@ -68,6 +70,7 @@ export function EditorialNoteMenu({
const [, startTransition] = useTransition()
const [showReminder, setShowReminder] = useState(false)
const [movePickerOpen, setMovePickerOpen] = useState(false)
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const handleDelete = (e: React.MouseEvent) => {
@@ -76,11 +79,15 @@ export function EditorialNoteMenu({
onDeleteNote(note)
return
}
setConfirmDeleteOpen(true)
}
const confirmFallbackDelete = () => {
startTransition(async () => {
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée')
showNoteTrashedToast(note, t)
} catch {
toast.error(t('general.error'))
}
@@ -229,6 +236,12 @@ export function EditorialNoteMenu({
onSave={(date) => patchReminder(date)}
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 { LiveBlockExtension } from './tiptap-live-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 { CalloutExtension, insertCalloutBlock } from './tiptap-callout-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 { openNotePeek } from '@/lib/note-peek-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import { isTextSelection, type Editor } from '@tiptap/core'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
@@ -70,11 +70,11 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { NodeSelection } from '@tiptap/pm/state'
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
@@ -298,6 +298,9 @@ const slashCommands: SlashItem[] = [
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
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: '>',
command: (e) => { insertToggleBlock(e) },
@@ -385,7 +388,7 @@ function useImageInsert() {
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
@@ -589,6 +592,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
InteractiveDemoExtension,
ToggleExtension,
CalloutExtension,
OutlineExtension,
@@ -951,6 +955,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
setChartSuggestionsOpen(true)
}, [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((
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
@@ -1304,24 +1402,39 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<BubbleMenu
editor={editor}
className="notion-bubble-menu"
{...({
tippyOptions: {
appendTo: () => document.body,
zIndex: 99999,
fallbackPlacements: ['bottom', 'top']
appendTo={() => document.body}
shouldShow={({ editor: e, state, from, to, view, element }) => {
if (!e.isEditable || e.isActive('codeBlock')) return false
const { selection } = state
if (selection instanceof AllSelection) return false
if (selection instanceof NodeSelection) {
return selection.node.type.name === 'image'
}
} as any)}
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection
const isImage = e.isActive('image')
return (from !== to && !e.isActive('codeBlock')) || isImage
if (!isTextSelection(selection) || selection.empty) return false
const selectedText = state.doc.textBetween(from, to)
if (!selectedText.length) return false
// 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} />
</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} />
@@ -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 { requestAiConsent } = useAiConsent()
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('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('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...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 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('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('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'] },
@@ -2080,9 +2194,14 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
finally { setAiLoading(false) }
} else if (
item.title === 'Suggest Charts'
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|| item.title === (t('richTextEditor.slashCharts') || 'Proposer des graphiques')
) {
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')) {
deleteSlashText(); closeMenu()
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
@@ -2092,7 +2211,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else {
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
useEffect(() => {
@@ -2278,7 +2397,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const selectedItem = filtered[selectedIndex]
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)
return createPortal(

View File

@@ -78,7 +78,9 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
// Load saved queries from localStorage
useEffect(() => {
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))
} catch {}
}, [])
@@ -405,14 +407,20 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
if (!query.trim()) return
setSavedQueries(prev => {
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
})
}
const handleRemoveQuery = () => {
setSavedQueries(prev => {
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
})
}
@@ -671,7 +679,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
router.push(`/home?openNote=${activeMatch.noteId}`)
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} />
<span>{t('searchModal.openInEditor')}</span>

View File

@@ -35,7 +35,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
<Link
key={tab.id}
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 }}
>
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -1,5 +1,5 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
@@ -10,6 +10,7 @@ import { toast } from 'sonner';
import { format } from 'date-fns';
import { motion } from 'motion/react';
import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year';
@@ -22,6 +23,9 @@ interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
trialEndsAt?: string | null;
trialEligible?: boolean;
trialDays?: number;
billingEnabled?: boolean;
prices?: {
PRO: {
@@ -55,7 +59,7 @@ function getStripePromise(enabled: boolean) {
}
export function BillingPlans() {
const { t } = useLanguage();
const { t, language } = useLanguage();
const queryClient = useQueryClient();
const [interval, setInterval] = useState<Interval>('month');
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
@@ -65,6 +69,7 @@ export function BillingPlans() {
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
const plansSectionRef = useRef<HTMLDivElement>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({
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({
queryKey: ['usage', 'current'],
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(() => {
setIsCheckoutOpen(false);
setCheckoutClientSecret(null);
@@ -250,6 +278,12 @@ export function BillingPlans() {
const effectiveTier = status?.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 = [
{
@@ -266,11 +300,15 @@ export function BillingPlans() {
t('billing.freeF5'),
],
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'
? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
onClick: () => {},
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => {
if (effectiveTier !== 'BASIC') void handleCancelSubscription();
},
},
{
id: 'pro',
@@ -280,20 +318,21 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
t('billing.proFeature3', { count: providerCount }),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
],
current: effectiveTier === 'PRO',
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'
? 'bg-paper text-concrete cursor-default'
: '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',
@@ -302,19 +341,20 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
t('billing.businessFeature3', { count: providerCount }),
t('billing.businessFeature4'),
t('billing.businessFeature5'),
t('billing.businessFeature6'),
],
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'
? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
onClick: () => handleCheckout('BUSINESS'),
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => handleChangePlan('BUSINESS'),
},
{
id: 'enterprise',
@@ -333,22 +373,24 @@ export function BillingPlans() {
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
buttonClass: effectiveTier === 'ENTERPRISE'
? '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'; },
},
];
const plansToShow = isPaid ? plans.filter((p) => p.id !== 'free') : plans;
const plansToShow = plans;
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '—';
try {
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, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
...(language === 'fa' ? { calendar: 'persian' as const } : {}),
}).format(date);
} catch (e) {
return dateStr;
@@ -415,7 +457,9 @@ export function BillingPlans() {
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
isTrialing
? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-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'
)}>
@@ -425,6 +469,12 @@ export function BillingPlans() {
)}
</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 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
@@ -439,28 +489,33 @@ export function BillingPlans() {
</div>
<div className="space-y-1">
<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>
<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>
</div>
</div>
)}
{isPaid && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handlePortal}
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"
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"
>
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
{t('billing.manageBilling') || 'Gérer la facturation'}
{t('billing.changeOffer')}
</button>
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
{isPaid && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handleCancelSubscription}
@@ -468,10 +523,27 @@ export function BillingPlans() {
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"}
{t('billing.cancelSubscription')}
</button>
)}
{isPaid && status?.hasStripeSubscription && (
<button
type="button"
onClick={handlePortal}
disabled={portalLoading}
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" />}
{t('billing.manageBilling')}
</button>
)}
</div>
{isPaid && status?.cancelAtPeriodEnd && (
<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">
{t('billing.cancellingNotice', { date: formatDate(status.currentPeriodEnd) })}
</p>
)}
</div>
@@ -572,7 +644,7 @@ export function BillingPlans() {
type="button"
onClick={() => handleBuyPack(pack.id)}
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 ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
@@ -619,6 +691,7 @@ export function BillingPlans() {
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
interactive_demo: t('usageMeter.featureInteractiveDemo'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
@@ -632,8 +705,8 @@ export function BillingPlans() {
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
const barFillColor =
pct >= 40
? 'bg-gradient-to-r from-violet-400 to-purple-400'
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
? 'bg-brand-accent'
: 'bg-brand-accent/60'
return (
<div
key={key}
@@ -664,8 +737,7 @@ export function BillingPlans() {
{isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */}
{!isPaid && (
<div className="space-y-8 pt-6 border-t border-border/40">
<div ref={plansSectionRef} className="space-y-8 pt-6 border-t border-border/40">
<div className="text-center space-y-2">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.upgradePlan') || 'Changer de plan'}
@@ -679,7 +751,7 @@ export function BillingPlans() {
onClick={() => setInterval('month')}
className={cn(
'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')}
@@ -689,7 +761,7 @@ export function BillingPlans() {
onClick={() => setInterval('year')}
className={cn(
'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')}
@@ -744,7 +816,12 @@ export function BillingPlans() {
<button
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)}
>
<div className="flex items-center justify-center gap-2">
@@ -756,7 +833,6 @@ export function BillingPlans() {
))}
</div>
</div>
)}
{/* 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">

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"
>
<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 ? (
<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">
{steps.map((step, i) => (
<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}
</span>
<span className="text-[12px] text-concrete leading-relaxed">
<span className="text-sm text-concrete leading-relaxed">
{step.text}
{step.link && (
<>

View File

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

View File

@@ -36,6 +36,13 @@ import {
Folder,
FolderOpen,
LayoutGrid,
Palette,
CreditCard,
Database,
Globe,
Plug,
Key,
Info,
} from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
@@ -67,7 +74,7 @@ import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
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'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
@@ -859,6 +866,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
}, [currentNotebookId, notebooks])
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const panelView: NavigationView = pathname.startsWith('/settings') ? 'settings' : activeView
const isInboxActive =
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 === '/insights') setActiveView('insights')
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 (isDashboardRoute) setActiveView('dashboard')
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}
>
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
<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">
{/* ── Column 1 : Rail icône + libellé — destinations lisibles sans survol ── */}
<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 */}
<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>
{/* 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',
icon: LayoutGrid,
label: t('nav.dashboard') || 'Dashboard',
label: t('nav.home'),
onClick: () => {
setActiveView('dashboard')
router.push('/home')
router.replace('/home')
},
isActive: isDashboardRoute,
},
@@ -1470,7 +1479,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{
id: 'agents',
icon: Bot,
label: t('agents.intelligenceOS') || 'Intelligence IA',
label: t('nav.agents'),
onClick: () => {
setActiveView('agents')
router.push('/agents')
@@ -1492,20 +1501,17 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
aria-current={item.isActive ? 'page' : undefined}
onClick={item.onClick}
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
? '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 && (
<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} />
<span
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.icon size={16} aria-hidden />
<span className="text-[11px] leading-tight font-semibold text-center max-w-full line-clamp-2">
{item.label}
</span>
</button>
@@ -1519,6 +1525,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link
href="/trash"
aria-label={t('sidebar.trash')}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/trash'
@@ -1538,6 +1545,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link
href="/archive"
aria-label={t('sidebar.archive')}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/archive'
@@ -1554,6 +1562,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link
href="/home?shared=1&forceList=1"
aria-label={t('sidebar.sharedWithMe')}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
searchParams.get('shared') === '1' && pathname === '/home'
@@ -1570,7 +1579,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button
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"
title="Ctrl+K"
aria-label={t('sidebar.searchShortcut')}
title={t('sidebar.searchShortcut')}
>
<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">
@@ -1580,6 +1590,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button
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"
>
{isDark ? <Sun size={15} /> : <Moon size={15} />}
@@ -1590,6 +1601,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link
href="/settings"
aria-label={t('nav.settings')}
onClick={() => setActiveView('settings')}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname.startsWith('/settings')
@@ -1606,6 +1619,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button
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"
>
<LogOut size={14} />
@@ -1623,14 +1637,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div
className={cn(
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
activeView === 'notebooks'
panelView === 'notebooks'
? 'overflow-hidden'
: 'overflow-y-auto custom-scrollbar space-y-6',
)}
>
<AnimatePresence mode="wait">
{activeView === 'dashboard' ? (
{panelView === 'dashboard' ? (
<motion.div
key="dashboard"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1649,45 +1663,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{t('sidebar.dashboardPanelBody')}
</p>
<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
type="button"
onClick={() => {
@@ -1701,7 +1676,54 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</button>
</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
key="notebooks"
ref={notebooksContainerRef}
@@ -1717,7 +1739,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="flex items-center gap-1.5">
<BookMarked size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('sidebar.documents')}
{t('nav.notebooks')}
</h3>
</div>
<div className="flex items-center gap-0.5">
@@ -1889,7 +1911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
</div>
</motion.div>
) : activeView === 'insights' ? (
) : panelView === 'insights' ? (
<motion.div
key="insights"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1911,12 +1933,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button"
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"
>
<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" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : activeView === 'revision' ? (
) : panelView === 'revision' ? (
<motion.div
key="revision"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1938,12 +1968,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button"
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"
>
<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" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : activeView === 'reminders' ? (
) : panelView === 'reminders' ? (
<motion.div
key="reminders"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
@@ -1962,7 +2000,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<SidebarReminders onOpenNote={handleReminderNoteClick} />
</div>
</motion.div>
) : activeView === 'agents' ? (
) : panelView === 'agents' ? (
<motion.div
key="agents"
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
@@ -1971,7 +2009,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
{t('agents.intelligenceOS')}
{t('nav.agents')}
</p>
<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