Compare commits
5 Commits
f385d43d5d
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbb0dfc2d | ||
|
|
ebf6f16fde | ||
|
|
e0fd5393ca | ||
|
|
80ccc1f6de | ||
|
|
69c99e4f4f |
45
.impeccable/config.json
Normal file
45
.impeccable/config.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"buildPath": "comp",
|
||||
"detector": {
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [],
|
||||
"ignoreValues": [
|
||||
{
|
||||
"rule": "ai-color-palette",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/components/intelligence-hub.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:03:40.105Z",
|
||||
"reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA."
|
||||
},
|
||||
{
|
||||
"rule": "side-tab",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/components/note-editor/note-editor-toolbar.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:14:54.764Z",
|
||||
"reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI."
|
||||
},
|
||||
{
|
||||
"rule": "border-accent-on-rounded",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/app/(main)/insights/page.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:23:06.241Z",
|
||||
"reason": "Styles existants de /insights (incumbent), pas une nouvelle carte."
|
||||
},
|
||||
{
|
||||
"rule": "gray-on-color",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/app/(main)/insights/page.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:23:06.270Z",
|
||||
"reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
88
PRODUCT.md
Normal file
88
PRODUCT.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Personnes qui capturent des idées, notes et savoirs au fil du temps et ont besoin de les retrouver, les relier et les actionner — pas seulement de les stocker.
|
||||
|
||||
Audiences confirmées (sans hiérarchie exclusive) :
|
||||
- étudiants et chercheurs (cours, papers, synthèses, révision) ;
|
||||
- freelances et professionnels indépendants (projets, briefs, clients) ;
|
||||
- équipes (partage de contexte, agents, Second Brain collectif selon le palier) ;
|
||||
- personnes « lambda » qui veulent un second cerveau accessible, pas un outil expert-only.
|
||||
|
||||
Situation type : trop de notes / dossiers, recherche trop littérale, idées perdues ; le job est de transformer la capture quotidienne en système vivant (liens, briefing, révision, actions).
|
||||
|
||||
## Product Purpose
|
||||
|
||||
**Memento** est une application de prise de notes intelligente positionnée comme **Second Brain** : elle relie les idées dans le temps, fait émerger des connexions cachées, et transforme les carnets en système searchable, briefable et actionnable (matin / routines).
|
||||
|
||||
Succès produit : l’utilisateur écrit dans Memento et le système lui rend de la valeur *pendant* et *après* l’écriture (Memory Echo, insights, dashboard, flashcards, agents) — pas une pile morte de fichiers.
|
||||
|
||||
## Positioning
|
||||
|
||||
Pas « une app de notes de plus ». Mécanisme différenciant : un Second Brain qui **se connecte pendant que vous écrivez** — similarité sémantique réelle, ponts entre notes/carnets, aperçu côte à côte, dashboard Second Brain (briefing, pistes, checklist), agents qui **écrivent dans les carnets**, recherche sémantique, révision espacée — plutôt qu’un chat jetable ou un classement manuel de dossiers.
|
||||
|
||||
Un concurrent « Keep/Notion générique » ne peut pas honnêtement reprendre la promesse *Second Brain vivant qui se connecte tout seul* sans ces mécanismes.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- App web principale : `memento-note/` (Next.js App Router), URL prod `https://memento-note.com`.
|
||||
- Capture & édition : notes riche / Markdown, carnets, tags, structured views (table/kanban), peek split note liée, publication web, pages interactives.
|
||||
- Second Brain au quotidien : dashboard `/home`, Memory Echo dans l’éditeur, `/insights` (clusters sémantiques), `/revision` (flashcards SM-2), agents, rappels, scan Gmail (vols/colis/abonnements).
|
||||
- Référence UX métier / prototype : `architectural-grid/` (et base `architectural-grid1/`) — à consulter avant implémentation UI significative ; la vérité produit d’interface suit le prototype quand il y a conflit d’interprétation.
|
||||
- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern).
|
||||
- Companion mobile Expo (`memento-mobile/`) existe ; **l’autorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais n’impose pas un langage UI `adaptive` tant qu’il n’est pas le surface principal de craft.
|
||||
- Auth : priorité Google OAuth ; email/mdp avec email vérifié obligatoire.
|
||||
- i18n : 15 langues ; calendrier iranien + chiffres persans + RTL pour le persan (et arabe) — app et extension.
|
||||
- Facturation : paliers BASIC/PRO/BUSINESS/ENTERPRISE, quotas IA, essai abonnement **7 jours** ; BASIC sans MCP ; BYOK possible selon config.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
**Capacités confirmées (non exhaustif)** : notes/carnets, recherche sémantique, Memory Echo, insights réseau, dashboard Second Brain configurable, agents, flashcards SM-2, structured views, Web Clipper, MCP (hors BASIC), publication simple/IA, pages interactives, multilinguisme.
|
||||
|
||||
**Contraintes durables** :
|
||||
- Nom produit : **Memento** uniquement (jamais « Momento ») dans libellés, metadata, pages publiques, docs.
|
||||
- Libellés UI via i18n (`memento-note/locales/*.json`) — pas de texte en dur ; pas de marques tierces dans les libellés utilisateur.
|
||||
- Qualité UX prioritaire ; solutions long terme plutôt que rustines.
|
||||
- Pas de perte de données : jamais de reset/drop/truncate DB sans backup vérifié + confirmation explicite (voir `CLAUDE.md` / règles projet).
|
||||
- Quotas IA : réservation atomique avant appel ; chaque usage IA décompte.
|
||||
- Une user story / feature à la fois, validation utilisateur avant la suivante.
|
||||
- Docs de stories souvent périmées — vérifier le code avant de « (re)développer ».
|
||||
|
||||
**Ouvert / hors scope Impeccable init** : détails de roadmap feature-par-feature ; choix esthétiques (monde visuel → `new-work` / `document`).
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- Marque : **Memento** ; entité visible « Memento Labs » dans certains pied-de-page.
|
||||
- Promesse marketing : Second Brain — « Il se souvient de ce que vous avez oublié » / se connecte pendant que vous écrivez.
|
||||
- Voix : claire, concrète, orientée bénéfice utilisateur ; français correct en communication produit FR ; éviter le jargon non expliqué dans l’UI.
|
||||
- Référence prototype `architectural-grid` pour les flux métier (liste/carte notes, recherche, insights, sidebar, etc.) — engagement produit confirmé, pas une esthétique figée dans ce fichier.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
- Copy landing et produit : `memento-note/locales/*.json` (ex. `landing.*`), `components/landing-page.tsx`.
|
||||
- README produit : `README.fr.md` / `README.md`, guide utilisateur `docs/guide-utilisateur/`.
|
||||
- Prototypes UI : `architectural-grid/`, `architectural-grid1/`.
|
||||
- Extension : `memento-note/extension/`.
|
||||
- Assets promo / démos : `promo-video/` (captures authentifiées, VO EN).
|
||||
- **Ne pas inventer** : témoignages clients, logos presse, benchmarks chiffrés non présents dans le repo.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. **Second Brain d’abord** — chaque surface renforce connexion, rappel et action ; pas le stockage pour le stockage.
|
||||
2. **Valeur pendant l’écriture** — Memory Echo, peek, agents et briefing doivent être exploitables dans le flux réel, pas décoratifs.
|
||||
3. **Accessible à la personne lambda, assez profond pour le chercheur** — clarté et aide contextuelle avant jargon ; puissance sans exiger d’être power-user dès le jour 1.
|
||||
4. **Monde multilingue réel** — RTL, calendriers et embeddings inclus ; pas d’anglais hardcodé quand l’UI est dans une autre langue.
|
||||
5. **Preuve avant promesse** — s’appuyer sur features et copy existantes ; ne pas fabriquer de preuves marketing.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
- Multilinguisme et RTL (fa/ar) sont des exigences produit, pas un bonus.
|
||||
- Aide contextuelle (« ? ») et libellés lisibles attendus là où l’UX le demande.
|
||||
- Standard WCAG formel non fixé dans cet init : à traiter comme **ouvert** ; viser une UI utilisable clavier / contraste correcte par défaut sur le web sans inventer une certification.
|
||||
@@ -31,3 +31,17 @@ All BYOK items implemented on 2026-05-30:
|
||||
|
||||
- **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story.
|
||||
- **Rate limiting absent sur `GET /api/user/export`** — vecteur d’abus (exports répétés) ; hardening ultérieur.
|
||||
|
||||
## Deferred — revue page interactive (2026-07-24)
|
||||
|
||||
- Régénération section/démo (§7.3 spec origine) + UI auteur associée + tarif regen.
|
||||
- Page publique interactive : `ReadingProgress` + `CopyLinkButton` (régression UX vs templates magazine/brief/essay).
|
||||
- Fallback déterministe : libellés complets dans les 13 autres locales + `dir="rtl"` pour fa/ar (actuellement FR/EN seulement).
|
||||
- `normalize.ts` : caps 2 démos/5 sections plus restrictifs que le validateur (5/8) — aligner si latence LLM acceptable.
|
||||
- `validateImageSrc` : autoriser uniquement `/uploads` + https (bloquer http:// et //domaine externe — pixel de tracking sur page publique).
|
||||
- Quality gate « règle d'or » appliquée aussi au `pageSpec` posté directement à `/api/notes/publish` (pas seulement au flux LLM).
|
||||
- `resolve.ts` : numérotation des badges par démo (pas réinitialisée par acte).
|
||||
- `demo-scene-view.tsx:479` : warning eslint exhaustive-deps (`edges` dans useMemo).
|
||||
- `page-view.tsx` useScrollReveal : scoper les `querySelectorAll` via ref (multi-instance).
|
||||
- Test `rejects hex color` : le faire échouer via le scan sémantique (pas l'enum Zod) pour prouver le scan.
|
||||
- Page publiée sans JS : démos à l'état final au premier paint (spec §9.5) — nécessite SSR static + swap hydratation.
|
||||
|
||||
@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin d’essai)
|
||||
- `invoice.payment_failed`
|
||||
4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
|
||||
|
||||
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
|
||||
|
||||
| Événement Stripe | Action Memento | Statut Prisma |
|
||||
|------------------|---------------|---------------|
|
||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
|
||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
|
||||
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
||||
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
||||
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
||||
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
|
||||
| `customer.subscription.trial_will_end` | Sync + email rappel fin d’essai | `TRIALING` |
|
||||
|
||||
### 3.3 Mapping statuts Stripe → Prisma
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **22 Tools** for notes, notebooks, labels, and reminders
|
||||
- ✅ **29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
|
||||
- 🔒 **API Key Authentication** with secure storage
|
||||
- 🚀 **Performance Optimized** with connection pooling and caching
|
||||
- 📊 **Observability** with Prometheus metrics export
|
||||
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
|
||||
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
```
|
||||
|
||||
## Available Tools (22)
|
||||
## Available Tools (29)
|
||||
|
||||
### Notes (13)
|
||||
|
||||
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
| `batch_move_notes` | Move multiple notes at once |
|
||||
| `batch_delete_notes` | Delete multiple notes at once |
|
||||
|
||||
### Notebooks (6)
|
||||
### Notebooks (7)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
| `update_label` | Update a label |
|
||||
| `delete_label` | Delete a label |
|
||||
|
||||
### Reminders (1)
|
||||
### Reminders (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_due_reminders` | Get due reminders |
|
||||
| `get_upcoming_reminders` | Get reminders due in the next N hours |
|
||||
| `update_reminder` | Set, update, or clear a note reminder |
|
||||
|
||||
### Semantic & AI (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `find_similar_notes` | Find notes semantically similar to a given note |
|
||||
| `get_memory_echo_insights` | List AI-discovered note connections |
|
||||
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
|
||||
|
||||
### Filters & Statistics (2)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_notes_by_label` | List notes by label/tag |
|
||||
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
|
||||
|
||||
### Utilities (2)
|
||||
|
||||
|
||||
@@ -197,8 +197,9 @@ export function validateConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Port validation
|
||||
const portValidation = validatePort(config.port);
|
||||
// Port validation (check raw value before clamping)
|
||||
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
|
||||
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
|
||||
if (!portValidation.valid) {
|
||||
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
|
||||
sessions: '/sessions',
|
||||
},
|
||||
auth: { enabled: config.requireAuth },
|
||||
tools: 22,
|
||||
tools: 29,
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
@@ -445,25 +445,23 @@ app.all(
|
||||
}
|
||||
|
||||
// Validate tool input if present
|
||||
if (req.body?.method) {
|
||||
const toolName = req.body.method;
|
||||
if (req.body?.params) {
|
||||
const validation = validateAndSanitize(toolName, req.body.params);
|
||||
if (!validation.success) {
|
||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||
detail: 'Input validation failed',
|
||||
field: validation.errors[0]?.field,
|
||||
context: { toolName, errors: validation.errors },
|
||||
})
|
||||
);
|
||||
}
|
||||
// Update request with sanitized data
|
||||
req.body.params = validation.data;
|
||||
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
|
||||
const toolName = req.body.params.name;
|
||||
const validation = validateAndSanitize(toolName, req.body.params.arguments);
|
||||
if (!validation.success) {
|
||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||
detail: 'Input validation failed',
|
||||
field: validation.errors[0]?.field,
|
||||
context: { toolName, errors: validation.errors },
|
||||
})
|
||||
);
|
||||
}
|
||||
// Update request with sanitized data
|
||||
req.body.params.arguments = validation.data;
|
||||
}
|
||||
|
||||
const ctx = { userId: req.userSession?.userId || null };
|
||||
@@ -539,7 +537,7 @@ async function main() {
|
||||
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
||||
Timeout: ${config.requestTimeout}ms
|
||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||
Tools: 22
|
||||
Tools: 29
|
||||
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ async function main() {
|
||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||
User: ${config.userId || 'all'}
|
||||
Log Level: ${config.logLevel}
|
||||
Tools: 22
|
||||
Tools: 29
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "memento-mcp-server",
|
||||
"version": "3.2.0",
|
||||
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
|
||||
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
|
||||
|
||||
it('should allow safe HTML', () => {
|
||||
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
||||
// This will be true because we check for any HTML tags
|
||||
// In production, you might want more sophisticated checking
|
||||
expect(xss).toBe(true);
|
||||
// Safe formatting tags are not flagged as XSS
|
||||
expect(xss).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input', () => {
|
||||
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
|
||||
'update_label',
|
||||
'delete_label',
|
||||
'get_due_reminders',
|
||||
'get_upcoming_reminders',
|
||||
'update_reminder',
|
||||
'find_similar_notes',
|
||||
'get_memory_echo_insights',
|
||||
'dismiss_memory_echo_insight',
|
||||
'get_notes_by_label',
|
||||
'get_note_statistics',
|
||||
'export_notes',
|
||||
'import_notes',
|
||||
];
|
||||
|
||||
it('should have all expected tools with schemas', () => {
|
||||
it('should have all expected tools with schemas', async () => {
|
||||
const { toolSchemas } = await import('../validation.js');
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
|
||||
const MAX_NOTES_LIMIT = 500;
|
||||
|
||||
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
export function parseNote(dbNote) {
|
||||
if (!dbNote) return null;
|
||||
@@ -463,6 +463,100 @@ const toolDefinitions = [
|
||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_upcoming_reminders',
|
||||
description: 'Get notes with reminders due in the next N hours (default 24).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
|
||||
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_reminder',
|
||||
description: 'Set, update, or clear a reminder on a note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note ID' },
|
||||
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
|
||||
isReminderDone: { type: 'boolean' },
|
||||
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
|
||||
reminderLocation: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
{
|
||||
name: 'find_similar_notes',
|
||||
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source note ID' },
|
||||
limit: { type: 'number', description: 'Max results', default: 10 },
|
||||
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_memory_echo_insights',
|
||||
description: 'List Memory Echo insights: AI-discovered connections between notes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
|
||||
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dismiss_memory_echo_insight',
|
||||
description: 'Mark a Memory Echo insight as viewed or dismissed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Insight ID' },
|
||||
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
|
||||
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
{
|
||||
name: 'get_notes_by_label',
|
||||
description: 'List notes that have a specific label/tag.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Label text to match' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
|
||||
includeArchived: { type: 'boolean', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_note_statistics',
|
||||
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
|
||||
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
|
||||
title: args.title || null,
|
||||
content: args.content,
|
||||
color: args.color || 'default',
|
||||
type: args.type || 'text',
|
||||
type: args.type || 'richtext',
|
||||
checkItems: args.checkItems ?? null,
|
||||
labels: args.labels ?? null,
|
||||
isPinned: args.isPinned || false,
|
||||
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
|
||||
return textResult({ count: reminders.length, reminders });
|
||||
}
|
||||
|
||||
case 'get_upcoming_reminders': {
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
|
||||
const extra = {
|
||||
reminder: { not: null, gte: now, lte: horizon },
|
||||
isArchived: false,
|
||||
};
|
||||
if (!args.includeDone) extra.isReminderDone = false;
|
||||
|
||||
const reminders = await prisma.note.findMany({
|
||||
where: noteWhere(uid, extra),
|
||||
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
|
||||
orderBy: { reminder: 'asc' },
|
||||
take: args.limit || 100,
|
||||
});
|
||||
|
||||
return textResult({ count: reminders.length, horizon, reminders });
|
||||
}
|
||||
|
||||
case 'update_reminder': {
|
||||
const d = { updatedAt: new Date() };
|
||||
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
|
||||
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
|
||||
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
data: d,
|
||||
});
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
case 'find_similar_notes': {
|
||||
const source = await prisma.note.findUnique({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
|
||||
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
|
||||
where: { noteId: args.id },
|
||||
select: { embedding: true },
|
||||
});
|
||||
if (!sourceEmbedding) {
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
error: 'Source note has no embedding. Index it first via the Memento app.',
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
|
||||
? sourceEmbedding.embedding
|
||||
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
|
||||
const vecStr = `[${embeddingArray.join(',')}]`;
|
||||
const limit = Math.min(args.limit || 10, 50);
|
||||
const threshold = args.threshold ?? 0.7;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
|
||||
n."notebookId", n."createdAt", n."updatedAt",
|
||||
1 - (e."embedding"::vector <=> $1::vector) AS similarity
|
||||
FROM "Note" n
|
||||
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
|
||||
WHERE n."trashedAt" IS NULL
|
||||
AND n."isArchived" = false
|
||||
AND n."userId" = $2
|
||||
AND n.id != $3
|
||||
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
|
||||
ORDER BY e."embedding"::vector <=> $1::vector ASC
|
||||
LIMIT $5`,
|
||||
vecStr,
|
||||
uid,
|
||||
args.id,
|
||||
threshold,
|
||||
limit,
|
||||
);
|
||||
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
sourceTitle: source.title || 'Untitled',
|
||||
count: rows.length,
|
||||
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
|
||||
});
|
||||
}
|
||||
|
||||
case 'get_memory_echo_insights': {
|
||||
const where = { userId: uid };
|
||||
if (!args.includeViewed) where.viewed = false;
|
||||
if (!args.includeDismissed) where.dismissed = false;
|
||||
|
||||
const insights = await prisma.memoryEchoInsight.findMany({
|
||||
where,
|
||||
orderBy: { similarityScore: 'desc' },
|
||||
take: Math.min(args.limit || 20, 100),
|
||||
include: {
|
||||
note1: { select: { id: true, title: true } },
|
||||
note2: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return textResult({
|
||||
count: insights.length,
|
||||
insights: insights.map(i => ({
|
||||
id: i.id,
|
||||
insight: i.insight,
|
||||
similarityScore: i.similarityScore,
|
||||
insightDate: i.insightDate,
|
||||
viewed: i.viewed,
|
||||
dismissed: i.dismissed,
|
||||
note1: i.note1,
|
||||
note2: i.note2,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
case 'dismiss_memory_echo_insight': {
|
||||
const d = {};
|
||||
if ('viewed' in args) d.viewed = args.viewed;
|
||||
if ('dismissed' in args) d.dismissed = args.dismissed;
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.update({
|
||||
where: { id: args.id, userId: uid },
|
||||
data: d,
|
||||
});
|
||||
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
|
||||
}
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
case 'get_notes_by_label': {
|
||||
const label = args.label.trim();
|
||||
const extra = {};
|
||||
if (!args.includeArchived) extra.isArchived = false;
|
||||
if (args.notebookId) {
|
||||
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||
}
|
||||
|
||||
// Labels are stored as a JSON string like ["tag1","tag2"].
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...noteWhere(uid, extra),
|
||||
labels: { contains: label },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
|
||||
});
|
||||
|
||||
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
|
||||
}
|
||||
|
||||
case 'get_note_statistics': {
|
||||
const baseWhere = { userId: uid };
|
||||
const activeWhere = { ...baseWhere, trashedAt: null };
|
||||
|
||||
const [
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
notesByType,
|
||||
notesByColor,
|
||||
] = await Promise.all([
|
||||
prisma.note.count({ where: activeWhere }),
|
||||
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
|
||||
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
|
||||
prisma.notebook.count({ where: { userId: uid } }),
|
||||
prisma.label.count({ where: { notebook: { userId: uid } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
|
||||
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
|
||||
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
|
||||
]);
|
||||
|
||||
return textResult({
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
activeNotes: totalNotes - archivedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
|
||||
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ TRASH ═══
|
||||
case 'trash_note': {
|
||||
const note = await prisma.note.update({
|
||||
|
||||
@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
fullDetails: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
|
||||
*/
|
||||
export const searchNotesSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
includeArchived: boolSchema(false),
|
||||
});
|
||||
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
|
||||
*/
|
||||
export const togglePinSchema = z.object({
|
||||
id: idSchema,
|
||||
pinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
|
||||
*/
|
||||
export const toggleArchiveSchema = z.object({
|
||||
id: idSchema,
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* batch_move_notes input schema
|
||||
*/
|
||||
export const batchMoveNotesSchema = z.object({
|
||||
noteIds: z.array(idSchema).min(1).max(100),
|
||||
ids: z.array(idSchema).min(1).max(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
|
||||
* batch_delete_notes input schema
|
||||
*/
|
||||
export const batchDeleteNotesSchema = z.object({
|
||||
noteIds: z.array(idSchema).min(1).max(100),
|
||||
ids: z.array(idSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
|
||||
*/
|
||||
export const createNotebookSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
color: colorSchema.default('default'),
|
||||
color: z.string().max(50).default('#3B82F6'),
|
||||
icon: z.string().max(50).optional().nullable(),
|
||||
parentId: idSchema.optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_notebooks input schema
|
||||
*/
|
||||
export const getNotebooksSchema = z.object({
|
||||
includeHierarchy: boolSchema(false),
|
||||
includeTrashed: boolSchema(false),
|
||||
});
|
||||
export const getNotebooksSchema = z.object({});
|
||||
|
||||
/**
|
||||
* get_notebook input schema
|
||||
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
|
||||
export const updateNotebookSchema = z.object({
|
||||
id: idSchema,
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
color: colorSchema.optional(),
|
||||
color: z.string().max(50).optional().nullable(),
|
||||
icon: z.string().max(50).optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
parentId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
|
||||
*/
|
||||
export const createLabelSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
color: colorSchema.default('default'),
|
||||
color: z.string().max(50).optional().nullable(),
|
||||
notebookId: idSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* get_labels input schema
|
||||
*/
|
||||
export const getLabelsSchema = z.object({
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
|
||||
/**
|
||||
* get_due_reminders input schema
|
||||
*/
|
||||
export const getDueRemindersSchema = z.object({
|
||||
before: isoDateSchema.optional().nullable(),
|
||||
after: isoDateSchema.optional().nullable(),
|
||||
includeDone: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
export const getDueRemindersSchema = z.object({});
|
||||
|
||||
/**
|
||||
* export_notes input schema
|
||||
*/
|
||||
export const exportNotesSchema = z.object({
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
includeArchived: boolSchema(false),
|
||||
format: z.enum(['json', 'markdown']).default('json'),
|
||||
});
|
||||
export const exportNotesSchema = z.object({});
|
||||
|
||||
/**
|
||||
* import_notes input schema
|
||||
*/
|
||||
export const importNotesSchema = z.object({
|
||||
notes: z.array(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
content: z.string(),
|
||||
color: colorSchema.optional(),
|
||||
labels: labelsSchema,
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
})
|
||||
).min(1).max(100),
|
||||
data: z.object({
|
||||
version: z.string().optional(),
|
||||
data: z.object({
|
||||
notes: z.array(z.any()).optional(),
|
||||
labels: z.array(z.any()).optional(),
|
||||
notebooks: z.array(z.any()).optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* find_similar_notes input schema
|
||||
*/
|
||||
export const findSimilarNotesSchema = z.object({
|
||||
id: idSchema,
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
threshold: z.number().min(0).max(1).default(0.7),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_memory_echo_insights input schema
|
||||
*/
|
||||
export const getMemoryEchoInsightsSchema = z.object({
|
||||
includeViewed: boolSchema(false),
|
||||
includeDismissed: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
/**
|
||||
* dismiss_memory_echo_insight input schema
|
||||
*/
|
||||
export const dismissMemoryEchoInsightSchema = z.object({
|
||||
id: idSchema,
|
||||
viewed: boolSchema(true),
|
||||
dismissed: boolSchema(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_notes_by_label input schema
|
||||
*/
|
||||
export const getNotesByLabelSchema = z.object({
|
||||
label: z.string().min(1).max(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
includeArchived: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_note_statistics input schema
|
||||
*/
|
||||
export const getNoteStatisticsSchema = z.object({
|
||||
includeArchived: boolSchema(false),
|
||||
includeTrashed: boolSchema(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_upcoming_reminders input schema
|
||||
*/
|
||||
export const getUpcomingRemindersSchema = z.object({
|
||||
hours: z.number().int().min(1).max(168).default(24),
|
||||
includeDone: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
/**
|
||||
* update_reminder input schema
|
||||
*/
|
||||
export const updateReminderSchema = z.object({
|
||||
id: idSchema,
|
||||
reminder: isoDateSchema,
|
||||
isReminderDone: z.boolean().optional(),
|
||||
reminderRecurrence: recurrenceSchema,
|
||||
reminderLocation: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -427,6 +476,13 @@ export const toolSchemas = {
|
||||
get_due_reminders: getDueRemindersSchema,
|
||||
export_notes: exportNotesSchema,
|
||||
import_notes: importNotesSchema,
|
||||
find_similar_notes: findSimilarNotesSchema,
|
||||
get_memory_echo_insights: getMemoryEchoInsightsSchema,
|
||||
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
|
||||
get_notes_by_label: getNotesByLabelSchema,
|
||||
get_note_statistics: getNoteStatisticsSchema,
|
||||
get_upcoming_reminders: getUpcomingRemindersSchema,
|
||||
update_reminder: updateReminderSchema,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
errors: error.errors.map((e) => ({
|
||||
errors: error.issues.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
|
||||
7
mcp-server/vitest.config.js
Normal file
7
mcp-server/vitest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/test.js', 'test/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
|
||||
import {
|
||||
CreditCard,
|
||||
Gauge,
|
||||
Coins,
|
||||
Package,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PriceHealth = {
|
||||
key: string
|
||||
priceId: string
|
||||
configured: boolean
|
||||
source: string
|
||||
stripeAmount: number | null
|
||||
stripeCurrency: string | null
|
||||
stripeActive: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type BillingAdminData = {
|
||||
billingConfig: Record<string, string>
|
||||
@@ -19,7 +44,6 @@ type BillingAdminData = {
|
||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||
}
|
||||
tiers: string[]
|
||||
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
||||
creditAllocations?: Array<{
|
||||
tier: string
|
||||
monthlyCredits: number | null
|
||||
@@ -31,6 +55,35 @@ type BillingAdminData = {
|
||||
credits: number
|
||||
defaultDisplay: string
|
||||
}>
|
||||
stripeHealth?: {
|
||||
secretConfigured: boolean
|
||||
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
|
||||
publishableConfigured: boolean
|
||||
webhookSecretConfigured: boolean
|
||||
billingEnabled: boolean
|
||||
trialDays: number
|
||||
prices: PriceHealth[]
|
||||
}
|
||||
subscriptionStats?: {
|
||||
byTier: Record<string, number>
|
||||
byStatus: Record<string, number>
|
||||
cancelAtPeriodEnd: number
|
||||
usersWithoutSub: number
|
||||
trialing: number
|
||||
pastDue: number
|
||||
paidActive: number
|
||||
recent: Array<{
|
||||
email: string
|
||||
name: string | null
|
||||
tier: string
|
||||
status: string
|
||||
trialEndsAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
hasStripeSub: boolean
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_PRICE_KEYS = [
|
||||
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_CREDITS_L',
|
||||
] as const
|
||||
|
||||
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
||||
function formatStableUtc(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
||||
}
|
||||
|
||||
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
|
||||
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
||||
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
|
||||
}
|
||||
|
||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||
const { t } = useLanguage()
|
||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||
const [showTestGuide, setShowTestGuide] = useState(true)
|
||||
|
||||
const health = initialData.stripeHealth
|
||||
const stats = initialData.subscriptionStats
|
||||
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
|
||||
|
||||
const handleSaveBilling = async (formData: FormData) => {
|
||||
setIsSavingBilling(true)
|
||||
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
}
|
||||
}
|
||||
|
||||
const modeLabel =
|
||||
health?.secretMode === 'test'
|
||||
? t('admin.billing.modeTest')
|
||||
: health?.secretMode === 'live'
|
||||
? t('admin.billing.modeLive')
|
||||
: health?.secretMode === 'placeholder'
|
||||
? t('admin.billing.modePlaceholder')
|
||||
: t('admin.billing.modeMissing')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Stripe health */}
|
||||
{health && (
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.publishableConfigured} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.webhookSecretConfigured} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(health.prices ?? []).map((row) => (
|
||||
<tr key={row.key} className="border-t border-border/50">
|
||||
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
|
||||
<td className="p-2.5 font-mono truncate max-w-[180px]">
|
||||
{row.configured ? row.priceId : '—'}
|
||||
</td>
|
||||
<td className="p-2.5">{row.source}</td>
|
||||
<td className="p-2.5">
|
||||
{!row.configured ? (
|
||||
<span className="text-rose-600">{t('admin.billing.missing')}</span>
|
||||
) : row.error ? (
|
||||
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
|
||||
) : row.stripeAmount != null ? (
|
||||
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
|
||||
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
|
||||
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription stats */}
|
||||
{stats && (
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{Object.entries(stats.byTier).map(([tier, count]) => (
|
||||
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
|
||||
<span>{tier}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between text-xs text-muted-foreground pt-1">
|
||||
<span>{t('admin.billing.usersWithoutSub')}</span>
|
||||
<span className="tabular-nums">{stats.usersWithoutSub}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{Object.keys(stats.byStatus).length === 0 ? (
|
||||
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
|
||||
) : (
|
||||
Object.entries(stats.byStatus).map(([status, count]) => (
|
||||
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
|
||||
<span>{status}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
|
||||
{stats.recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.recent.map((row) => (
|
||||
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
|
||||
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
|
||||
<td className="p-2.5">{row.tier}</td>
|
||||
<td className="p-2.5">
|
||||
{row.status}
|
||||
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
|
||||
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
|
||||
</td>
|
||||
<td className="p-2.5 text-muted-foreground">
|
||||
{row.trialEndsAt
|
||||
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
|
||||
: row.currentPeriodEnd
|
||||
? formatStableUtc(row.currentPeriodEnd)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How to test Stripe */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTestGuide((v) => !v)}
|
||||
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
|
||||
</div>
|
||||
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showTestGuide && (
|
||||
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
|
||||
<ol className="list-decimal ps-5 space-y-2">
|
||||
<li>{t('admin.billing.testStep1')}</li>
|
||||
<li>{t('admin.billing.testStep2')}</li>
|
||||
<li>
|
||||
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
|
||||
{' — '}{t('admin.billing.testStep3')}
|
||||
</li>
|
||||
<li>{t('admin.billing.testStep4')}</li>
|
||||
<li>{t('admin.billing.testStep5')}</li>
|
||||
<li>{t('admin.billing.testStep6')}</li>
|
||||
</ol>
|
||||
<p className="text-xs border-t border-border/50 pt-3">
|
||||
{t('admin.billing.testCardHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
|
||||
const meta = priceMap[key]
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
{meta?.stripeAmount != null && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
|
||||
</p>
|
||||
)}
|
||||
{meta?.error && (
|
||||
<p className="text-[11px] text-rose-600">{meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Solde unique — source de vérité du débit */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||
{initialData.usageOverview.lastSyncedAt
|
||||
? ` · ${t('admin.billing.lastSync', {
|
||||
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||
})}`
|
||||
: ` · ${t('admin.billing.notSynced')}`}
|
||||
|
||||
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Mail, ArrowLeft, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { resendSignupVerification } from '@/app/actions/auth-verify'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function CheckEmailContent() {
|
||||
const { t, language } = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const initialEmail = searchParams.get('email') ?? ''
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const handleResend = async () => {
|
||||
const target = email.trim()
|
||||
if (!target) {
|
||||
toast.error(t('auth.verifyMissingEmail'))
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
const result = await resendSignupVerification(target, language)
|
||||
setSending(false)
|
||||
if (result.success) {
|
||||
toast.success(t('auth.verifyResent'))
|
||||
} else {
|
||||
toast.error(t('auth.verifyResendFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
|
||||
<Mail size={28} className="text-[var(--color-brand-accent)]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl md:text-3xl font-serif font-bold">
|
||||
{t('auth.checkEmailTitle')}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
|
||||
{initialEmail
|
||||
? t('auth.checkEmailDescription', { email: initialEmail })
|
||||
: t('auth.checkEmailDescriptionGeneric')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-start">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={sending}
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CheckEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||
<CheckEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +1,22 @@
|
||||
'use client';
|
||||
import { headers } from 'next/headers';
|
||||
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language';
|
||||
import { loadTranslations } from '@/lib/i18n/load-translations';
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
|
||||
import { AuthShell } from '@/components/auth-shell';
|
||||
|
||||
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
function AuthHeader() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('general.back')}
|
||||
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
||||
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthLayout({
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const headersList = await headers();
|
||||
const browserLang = parseAcceptLanguage(headersList.get('accept-language'));
|
||||
const initialLanguage = await detectUserLanguage(browserLang);
|
||||
const initialTranslations = await loadTranslations(initialLanguage);
|
||||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
|
||||
<AuthHeader />
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2026 Memento
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<LanguageProvider initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<AuthShell>{children}</AuthShell>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { confirmEmail } from '@/app/actions/auth-verify'
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const { t } = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('invalid')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const result = await confirmEmail(token)
|
||||
if (cancelled) return
|
||||
if (result.success) setStatus('ok')
|
||||
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
|
||||
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'ok') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 size={28} className="text-emerald-600" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
|
||||
</div>
|
||||
<Link href="/login?verified=1" className="block">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||
>
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||
<AlertCircle size={28} className="text-red-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">
|
||||
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{status === 'expired'
|
||||
? t('auth.verifyExpiredDescription')
|
||||
: t('auth.verifyInvalidDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/check-email" className="block">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||
>
|
||||
{t('auth.resendVerification')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage } from '@/lib/interactive-page'
|
||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||
|
||||
/**
|
||||
* Dev preview of PageView + thermo fixture (spec §8.3).
|
||||
* Only available outside production.
|
||||
*/
|
||||
export default function InteractivePagePreviewPage() {
|
||||
if (process.env.NODE_ENV === 'production') notFound()
|
||||
|
||||
const parsed = validateInteractivePage(thermoFixture)
|
||||
if (!parsed.ok) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-destructive">
|
||||
Fixture invalide : {parsed.issues[0]?.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageView page={parsed.page} demoMode="interactive" />
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu } from 'lucide-react'
|
||||
import { SettingsNav } from '@/components/settings'
|
||||
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function SettingsLayout({
|
||||
@@ -11,9 +12,10 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<SettingsDocumentTitle />
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<button
|
||||
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
@@ -22,10 +24,10 @@ export default function SettingsLayout({
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
|
||||
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function McpSettingsHeader() {
|
||||
{ text: t('mcpSettings.helpBox.step2') },
|
||||
{ text: t('mcpSettings.helpBox.step3') },
|
||||
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
|
||||
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
|
||||
{ text: t('mcpSettings.helpBox.step5') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
import { ReadingProgress } from '@/components/publish/reading-progress'
|
||||
import { CopyLinkButton } from '@/components/publish/copy-link-button'
|
||||
import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
|
||||
import { isInteractivePageTemplate } from '@/lib/publish/types'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
if (
|
||||
isInteractivePageTemplate(note.publishedTemplate) &&
|
||||
note.publishedContent
|
||||
) {
|
||||
return (
|
||||
<InteractivePublishedPage
|
||||
publishedContent={note.publishedContent}
|
||||
isStale={isStale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const usesAiLayout = Boolean(note.publishedContent)
|
||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||
|
||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const readingTime = estimateReadingTime(readingSource)
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||
|
||||
|
||||
@@ -1,89 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Check } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||
const { data: byokCatalog } = useQuery({
|
||||
queryKey: ['public', 'byok-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/public/byok-catalog')
|
||||
if (!res.ok) throw new Error('catalog')
|
||||
return res.json() as Promise<{ providers: { id: string }[] }>
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const providerCount = byokCatalog?.providers.length || '…'
|
||||
|
||||
const PLANS = [
|
||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
||||
const plans = [
|
||||
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{
|
||||
key: 'pro',
|
||||
popular: true,
|
||||
hasTrial: true,
|
||||
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
|
||||
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
popular: false,
|
||||
hasTrial: true,
|
||||
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
|
||||
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||
},
|
||||
{
|
||||
key: 'enterprise',
|
||||
popular: false,
|
||||
hasTrial: false,
|
||||
price: t('landing.pricing.enterprisePrice'),
|
||||
period: '',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-paper">
|
||||
<section className="py-32 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
||||
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
|
||||
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
|
||||
{t('landing.nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
|
||||
>
|
||||
{t('landing.nav.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-center gap-10 mb-8">
|
||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
||||
{billingInterval === 'monthly' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
<section className="px-5 sm:px-8 py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
|
||||
{t('landing.pricing.label')}
|
||||
</span>
|
||||
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
|
||||
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||
{t('landing.pricing.savePercent')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
||||
{billingInterval === 'annual' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`rounded-2xl border p-6 flex flex-col ${
|
||||
plan.popular
|
||||
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
|
||||
: 'border-white/[0.08] bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||
</div>
|
||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
|
||||
{t(`landing.pricing.${plan.key}.name`)}
|
||||
</h2>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-serif">{plan.price}</span>
|
||||
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 mb-10">
|
||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||
{plan.hasTrial && (
|
||||
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<ul className="space-y-2.5 mb-8 flex-1">
|
||||
{plan.hasTrial && (
|
||||
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{t('landing.pricing.trialFeature', { days: trialDays })}
|
||||
</li>
|
||||
)}
|
||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
|
||||
if (!feat || feat.startsWith('landing.')) return null
|
||||
return (
|
||||
<div key={j} className="flex items-start gap-3">
|
||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||
<Shield size={10} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xs font-light">{feat}</span>
|
||||
</div>
|
||||
<li key={j} className="flex gap-2 text-sm text-white/80">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/register')}
|
||||
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
|
||||
plan.popular
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{t(`landing.pricing.${plan.key}.cta`)}
|
||||
</button>
|
||||
{plan.hasTrial
|
||||
? t('landing.pricing.trialCta', { days: trialDays })
|
||||
: t(`landing.pricing.${plan.key}.cta`)}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
alternates: { canonical: 'https://memento-note.com/privacy' },
|
||||
robots: { index: true, follow: true },
|
||||
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">
|
||||
|
||||
@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
|
||||
}
|
||||
}
|
||||
|
||||
async function getStripeHealth(billingConfig: Record<string, string>) {
|
||||
const secret = process.env.STRIPE_SECRET_KEY ?? ''
|
||||
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
|
||||
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
||||
|
||||
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
|
||||
if (!secret) secretMode = 'missing'
|
||||
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
|
||||
else if (secret.startsWith('sk_live_')) secretMode = 'live'
|
||||
else if (secret.startsWith('sk_test_')) secretMode = 'test'
|
||||
else secretMode = 'placeholder'
|
||||
|
||||
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
|
||||
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
|
||||
const billingEnabled = await isBillingEnabled()
|
||||
|
||||
const priceKeys = [
|
||||
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
|
||||
] as string[]
|
||||
|
||||
const canCallStripe = secretMode === 'test' || secretMode === 'live'
|
||||
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
|
||||
if (canCallStripe) {
|
||||
try {
|
||||
const { getStripe } = await import('@/lib/stripe')
|
||||
stripe = getStripe()
|
||||
} catch {
|
||||
stripe = null
|
||||
}
|
||||
}
|
||||
|
||||
const prices = await Promise.all(
|
||||
priceKeys.map(async (key) => {
|
||||
const priceId = (billingConfig[key] || process.env[key] || '').trim()
|
||||
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
|
||||
let stripeAmount: number | null = null
|
||||
let stripeCurrency: string | null = null
|
||||
let stripeActive: boolean | null = null
|
||||
let error: string | null = null
|
||||
|
||||
if (configured && stripe) {
|
||||
try {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
|
||||
stripeCurrency = price.currency?.toUpperCase() ?? null
|
||||
stripeActive = price.active
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
priceId: configured ? priceId : '',
|
||||
configured,
|
||||
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
|
||||
stripeAmount,
|
||||
stripeCurrency,
|
||||
stripeActive,
|
||||
error,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
secretConfigured: secretMode === 'test' || secretMode === 'live',
|
||||
secretMode,
|
||||
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
|
||||
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
|
||||
billingEnabled,
|
||||
trialDays: SUBSCRIPTION_TRIAL_DAYS,
|
||||
prices,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscriptionStats() {
|
||||
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
|
||||
prisma.subscription.groupBy({
|
||||
by: ['tier'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.groupBy({
|
||||
by: ['status'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
|
||||
prisma.subscription.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
|
||||
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
trialEndsAt: true,
|
||||
currentPeriodEnd: true,
|
||||
cancelAtPeriodEnd: true,
|
||||
updatedAt: true,
|
||||
stripeSubscriptionId: true,
|
||||
user: { select: { email: true, name: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
||||
for (const row of byTierRaw) {
|
||||
byTier[row.tier] = row._count._all
|
||||
}
|
||||
|
||||
const byStatus: Record<string, number> = {}
|
||||
for (const row of byStatusRaw) {
|
||||
byStatus[row.status] = row._count._all
|
||||
}
|
||||
|
||||
const usersWithoutSub = await prisma.user.count({
|
||||
where: { subscription: null },
|
||||
})
|
||||
|
||||
return {
|
||||
byTier,
|
||||
byStatus,
|
||||
cancelAtPeriodEnd,
|
||||
usersWithoutSub,
|
||||
trialing: byStatus.TRIALING ?? 0,
|
||||
pastDue: byStatus.PAST_DUE ?? 0,
|
||||
paidActive:
|
||||
(byStatus.ACTIVE ?? 0) +
|
||||
(byStatus.TRIALING ?? 0),
|
||||
recent: recent.map((s) => ({
|
||||
email: s.user.email,
|
||||
name: s.user.name,
|
||||
tier: s.tier,
|
||||
status: s.status,
|
||||
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
hasStripeSub: Boolean(s.stripeSubscriptionId),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBillingAdminData() {
|
||||
await checkAdmin()
|
||||
const { getSystemConfig } = await import('@/lib/config')
|
||||
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
|
||||
}
|
||||
})
|
||||
|
||||
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||
getStripeHealth(billingConfig),
|
||||
getSubscriptionStats(),
|
||||
])
|
||||
|
||||
return {
|
||||
entitlements,
|
||||
billingConfig,
|
||||
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
|
||||
creditAllocations,
|
||||
creditCosts,
|
||||
creditPacks,
|
||||
stripeHealth,
|
||||
subscriptionStats,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
|
||||
import { addUtcMonths } from '@/lib/billing/period'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
||||
const oldTier = existing?.tier ?? 'BASIC'
|
||||
|
||||
const now = new Date()
|
||||
const periodEnd = new Date(now)
|
||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
||||
const periodEnd = addUtcMonths(now, 1)
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
|
||||
11
memento-note/app/actions/auth-verify.ts
Normal file
11
memento-note/app/actions/auth-verify.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
'use server'
|
||||
|
||||
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
|
||||
|
||||
export async function confirmEmail(token: string) {
|
||||
return verifyEmailToken(token)
|
||||
}
|
||||
|
||||
export async function resendSignupVerification(email: string, locale?: string) {
|
||||
return resendVerificationEmail(email, locale)
|
||||
}
|
||||
@@ -2,20 +2,49 @@
|
||||
|
||||
import { signIn } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function authenticate(
|
||||
prevState: string | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
const emailRaw = formData.get('email');
|
||||
const passwordRaw = formData.get('password');
|
||||
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
|
||||
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
|
||||
|
||||
// Surface a clear message when credentials are valid but email is unverified.
|
||||
if (email && password.length >= 6) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (user?.password) {
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (match && !user.emailVerified) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
}
|
||||
} catch (preCheckErr) {
|
||||
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
email,
|
||||
password,
|
||||
redirectTo: '/home',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error('AuthError details:', error.type, error.message);
|
||||
if (
|
||||
error.type === 'CredentialsSignin' &&
|
||||
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
|
||||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
|
||||
) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid credentials.';
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
|
||||
|
||||
export const maxDuration = 180
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(20),
|
||||
selection: z.string().optional().nullable(),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
})
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = requestSchema.parse(body)
|
||||
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
|
||||
const sourceRaw = parsed.selection?.trim() || parsed.content
|
||||
const sourcePlain = stripHtml(sourceRaw)
|
||||
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const lang = parsed.lang || 'fr'
|
||||
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
|
||||
const provider = getSlidesProvider(config)
|
||||
|
||||
const result = await generateInteractiveDemoFromContent({
|
||||
content: sourceRaw,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const first = result.issues[0]
|
||||
const detail = first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: ''
|
||||
console.error(
|
||||
'[/api/ai/interactive-demo] validation failed after repairs',
|
||||
result.issues.slice(0, 10)
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: detail
|
||||
? `Démo invalide après correction — ${detail}`
|
||||
: 'La génération a produit un JSON invalide après correction',
|
||||
issues: result.issues.slice(0, 10),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
demo: result.demo,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive demo'
|
||||
console.error('[/api/ai/interactive-demo]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||
import {
|
||||
generatePagePlan,
|
||||
generatePageSection,
|
||||
} from '@/lib/ai/services/interactive-page-llm.service'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
const sectionPlanSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
goal: z.string().min(1),
|
||||
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
||||
demoGoal: z.string().nullish(),
|
||||
})
|
||||
|
||||
const requestSchema = z.object({
|
||||
/** undefined = legacy deterministic full-page (fallback path) */
|
||||
action: z.enum(['plan', 'section']).optional(),
|
||||
content: z.string().min(40).max(500_000),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
notebookId: z.string().optional(),
|
||||
pageTitle: z.string().optional(),
|
||||
sectionId: z.string().optional(),
|
||||
section: sectionPlanSchema.optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
const parsed = requestSchema.parse(body)
|
||||
const wordCount = parsed.content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 40) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getSlidesProvider(config)
|
||||
const lang = parsed.lang || 'fr'
|
||||
|
||||
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
|
||||
if (parsed.action === 'plan') {
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||
lane: 'chat',
|
||||
amount: 5,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const result = await generatePagePlan({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, reason: result.reason, attempts: result.attempts },
|
||||
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
|
||||
if (parsed.action === 'section') {
|
||||
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
||||
return NextResponse.json(
|
||||
{ error: 'section, sectionId and pageTitle are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||
lane: 'chat',
|
||||
amount: 3,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||
}
|
||||
const result = await generatePageSection({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
pageTitle: parsed.pageTitle,
|
||||
sectionId: parsed.sectionId,
|
||||
section: parsed.section,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'section_generation_failed',
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ section: result.section, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
|
||||
const result = await generateInteractivePageFromContent({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error === 'unsuitable_content') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'unsuitable_content',
|
||||
reason: result.reason,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
const first = result.issues?.[0]
|
||||
const reason =
|
||||
result.reason ||
|
||||
(first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: undefined)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
result.error === 'timeout'
|
||||
? 'La génération a pris trop de temps — réessayez'
|
||||
: reason ||
|
||||
'La génération a produit une page invalide',
|
||||
reason,
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
page: result.page,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Requête invalide',
|
||||
issues: error.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
||||
console.error('[/api/ai/interactive-page]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication and user setting
|
||||
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { content: rawContent } = requestSchema.parse(body)
|
||||
|
||||
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
|
||||
const content = stripHtml(rawContent)
|
||||
const content = stripHtmlToPlainText(rawContent)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le contenu doit avoir au moins 10 mots' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
|
||||
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import {
|
||||
shouldOfferSubscriptionTrial,
|
||||
SUBSCRIPTION_TRIAL_DAYS,
|
||||
} from '@/lib/billing/trial';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||
const origin = `${proto}://${host}`;
|
||||
|
||||
const offerTrial = await shouldOfferSubscriptionTrial(userId);
|
||||
const subscriptionData: {
|
||||
metadata: { userId: string; tier: string };
|
||||
trial_period_days?: number;
|
||||
} = {
|
||||
metadata: { userId, tier },
|
||||
};
|
||||
if (offerTrial) {
|
||||
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
|
||||
}
|
||||
|
||||
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||
if (preferredMode === 'embedded') {
|
||||
try {
|
||||
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded' as any,
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
} as any);
|
||||
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
clientSecret: embedded.client_secret,
|
||||
sessionId: embedded.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
}
|
||||
} catch (embeddedErr) {
|
||||
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
|
||||
return NextResponse.json({
|
||||
url: checkoutSession.url,
|
||||
sessionId: checkoutSession.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[billing/create-checkout]', error);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||
|
||||
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
|
||||
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -23,56 +26,53 @@ export async function GET(req: NextRequest) {
|
||||
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
||||
const subId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
: (checkoutSession.subscription as any).id;
|
||||
: (checkoutSession.subscription as { id: string }).id;
|
||||
|
||||
const sub = await stripe.subscriptions.retrieve(subId) as any;
|
||||
const priceId = sub.items.data[0].price.id;
|
||||
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
|
||||
|
||||
const currentPeriodStartTimestamp =
|
||||
sub.current_period_start ??
|
||||
sub.items?.data?.[0]?.current_period_start ??
|
||||
sub.start_date ??
|
||||
Math.floor(Date.now() / 1000);
|
||||
|
||||
const currentPeriodEndTimestamp =
|
||||
sub.current_period_end ??
|
||||
sub.items?.data?.[0]?.current_period_end ??
|
||||
(currentPeriodStartTimestamp + 30 * 24 * 3600);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
tier,
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
stripeSubscriptionId: sub.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
||||
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tier,
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
stripeSubscriptionId: sub.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
||||
},
|
||||
});
|
||||
const sub = await stripe.subscriptions.retrieve(subId);
|
||||
const syncUserId =
|
||||
(checkoutSession.metadata?.userId as string | undefined) ??
|
||||
(sub.metadata?.userId as string | undefined) ??
|
||||
userId;
|
||||
if (syncUserId === userId) {
|
||||
await syncSubscriptionFromStripe(sub, userId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[billing/status] Failed to sync Stripe session:', err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
let subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const stripeSecret = process.env.STRIPE_SECRET_KEY;
|
||||
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
|
||||
|
||||
if (
|
||||
subscription?.stripeSubscriptionId
|
||||
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
|
||||
&& canTalkToStripe
|
||||
) {
|
||||
try {
|
||||
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
|
||||
await syncSubscriptionFromStripe(live, userId);
|
||||
subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
} catch (syncErr) {
|
||||
console.error('[billing/status] live Stripe period sync failed:', syncErr);
|
||||
}
|
||||
} else if (subscription && !subscription.cancelAtPeriodEnd) {
|
||||
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
|
||||
if (periodsDiffer(rolled, subscription)) {
|
||||
subscription = await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
currentPeriodStart: rolled.currentPeriodStart,
|
||||
currentPeriodEnd: rolled.currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
const effectiveTier = await getEffectiveTier(userId);
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const prices = await getDynamicPrices();
|
||||
const billingEnabled = await isBillingEnabled();
|
||||
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
|
||||
@@ -112,14 +112,19 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const trialEligible = await shouldOfferSubscriptionTrial(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
effectiveTier,
|
||||
status,
|
||||
currentPeriodEnd: currentPeriodEnd ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
|
||||
trialEligible,
|
||||
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
prices,
|
||||
creditPacks,
|
||||
billingEnabled,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
isCreditPackId,
|
||||
resolvePackFromPriceId,
|
||||
} from '@/lib/billing/credit-packs';
|
||||
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.trial_will_end': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const userId = await resolveUserIdFromStripeEvent(subscription);
|
||||
if (!userId) {
|
||||
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
|
||||
break;
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
aiSettings: { select: { preferredLanguage: true } },
|
||||
},
|
||||
});
|
||||
if (!user?.email) break;
|
||||
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
|
||||
const trialEndsAt = subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
|
||||
|
||||
const appUrl =
|
||||
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
|
||||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
|
||||
'https://memento-note.com';
|
||||
|
||||
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
|
||||
const mailResult = await sendTrialEndingReminder({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
trialEndsAt,
|
||||
billingUrl: `${appUrl}/settings/billing`,
|
||||
locale: preferred === 'auto' ? 'en' : preferred,
|
||||
});
|
||||
if (!mailResult.success) {
|
||||
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function GET() {
|
||||
const [
|
||||
recentNotes,
|
||||
inboxCount,
|
||||
inboxPreview,
|
||||
dueFlashcards,
|
||||
upcomingReminders,
|
||||
unviewedInsights,
|
||||
@@ -76,13 +77,20 @@ export async function GET() {
|
||||
notebookId: true, updatedAt: true, createdAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
take: 12,
|
||||
}),
|
||||
|
||||
prisma.note.count({
|
||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||
}),
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 3,
|
||||
}),
|
||||
|
||||
prisma.flashcard.count({
|
||||
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||
}),
|
||||
@@ -124,7 +132,7 @@ export async function GET() {
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
@@ -162,7 +170,10 @@ export async function GET() {
|
||||
insightRows = [...insightRows, ...viewedRecent]
|
||||
}
|
||||
|
||||
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||
const notebookIds = [...new Set([
|
||||
...recentNotes.map(n => n.notebookId),
|
||||
...pinnedNotes.map(n => n.notebookId),
|
||||
].filter(Boolean))] as string[]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({
|
||||
where: { id: { in: notebookIds } },
|
||||
@@ -177,6 +188,13 @@ export async function GET() {
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||
})),
|
||||
inboxCount,
|
||||
inboxPreview: inboxPreview.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
})),
|
||||
dueFlashcards,
|
||||
upcomingReminders: upcomingReminders.map(r => ({
|
||||
id: r.id,
|
||||
@@ -221,8 +239,10 @@ export async function GET() {
|
||||
pinnedNotes: pinnedNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||
})),
|
||||
writingActivity,
|
||||
agentSuggestions: agentSuggestions.map(s => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function GET() {
|
||||
|
||||
const userId = session.user.id
|
||||
const locale = await detectUserLanguage()
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}`
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey)
|
||||
@@ -39,7 +39,7 @@ export async function GET() {
|
||||
isArchived: false,
|
||||
updatedAt: { gte: weekAgo },
|
||||
},
|
||||
select: { title: true, content: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
@@ -64,10 +64,14 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
|
||||
const voice = locale === 'fr'
|
||||
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
|
||||
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
|
||||
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
|
||||
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
|
||||
${voice}
|
||||
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
|
||||
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
|
||||
|
||||
Notes:
|
||||
${snippets.slice(0, 3000)}`
|
||||
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const payload = { available: true, ...parsed }
|
||||
const relatedNotes = recentNotes.slice(0, 2).map(n => {
|
||||
const title = n.title?.trim()
|
||||
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return {
|
||||
id: n.id,
|
||||
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}…` : plain) : null),
|
||||
notebookId: n.notebookId,
|
||||
}
|
||||
})
|
||||
const payload = { available: true, ...parsed, relatedNotes }
|
||||
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
|
||||
return NextResponse.json(payload)
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicByokCatalog } from '@/lib/ai/byok-catalog'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Liste publique des fournisseurs / modèles BYOK — aucun secret. */
|
||||
export async function GET() {
|
||||
const providers = await getPublicByokCatalog()
|
||||
return NextResponse.json(
|
||||
{ providers },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400' } },
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import { isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
|
||||
import { decryptApiKey } from '@/lib/crypto';
|
||||
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
// Providers that return static suggestions regardless of key
|
||||
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
|
||||
|
||||
/**
|
||||
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
|
||||
*
|
||||
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
|
||||
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
|
||||
* Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
|
||||
* Pas de liste de secours figée.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const provider = searchParams.get('provider') as AiGatewayProvider;
|
||||
const apiKey = searchParams.get('key') ?? '';
|
||||
const baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
let apiKey = searchParams.get('key') ?? '';
|
||||
let baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
|
||||
@@ -42,16 +40,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Static suggestion providers: return immediately without a key
|
||||
if (STATIC_PROVIDERS.has(provider)) {
|
||||
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
|
||||
return NextResponse.json({ success: true, models, fromApi: false });
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
const saved = await getActiveByokKey(session.user.id, provider, tier);
|
||||
if (saved) {
|
||||
try {
|
||||
apiKey = await decryptApiKey(saved.encryptedKey);
|
||||
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
|
||||
} catch {
|
||||
apiKey = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live providers need a key
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
|
||||
return NextResponse.json({ success: true, models: [], fromApi: false });
|
||||
}
|
||||
|
||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
|
||||
@@ -42,8 +42,8 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Memento - Your Digital Notepad",
|
||||
description: "A beautiful note-taking app built with Next.js 16",
|
||||
title: "Memento — Your Second Brain",
|
||||
description: "A Second Brain that connects while you write. Search, brief, and act on your ideas every morning.",
|
||||
manifest: "/api/manifest",
|
||||
icons: {
|
||||
icon: "/icons/icon-512.svg",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,14 +63,26 @@ const templateConfig = [
|
||||
type TemplateId = typeof templateConfig[number]['id']
|
||||
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
|
||||
|
||||
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'veille', label: '📡 Veille' },
|
||||
{ id: 'digest', label: '📰 Digest' },
|
||||
{ id: 'tools', label: '🔧 Outils' },
|
||||
{ id: 'generate', label: '✨ Génération' },
|
||||
const CATEGORIES: { id: CategoryId; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'agents.templates.categoryAll' },
|
||||
{ id: 'veille', labelKey: 'agents.templates.categoryWatch' },
|
||||
{ id: 'digest', labelKey: 'agents.templates.categoryDigest' },
|
||||
{ id: 'tools', labelKey: 'agents.templates.categoryTools' },
|
||||
{ id: 'generate', labelKey: 'agents.templates.categoryGenerate' },
|
||||
]
|
||||
|
||||
const PREVIEW_COUNT = 3
|
||||
|
||||
function templateNameKey(id: string) {
|
||||
return `agents.templates.${id}.name`
|
||||
}
|
||||
|
||||
function hasTranslatedName(t: (key: string) => string, id: string) {
|
||||
const key = templateNameKey(id)
|
||||
const name = t(key)
|
||||
return Boolean(name.trim()) && name !== key
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
@@ -98,6 +110,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
@@ -142,55 +155,62 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = activeCategory === 'all'
|
||||
const filtered = (activeCategory === 'all'
|
||||
? templateConfig
|
||||
: templateConfig.filter((tpl) => tpl.category === activeCategory)
|
||||
).filter((tpl) => hasTranslatedName(t, tpl.id))
|
||||
|
||||
const visible = showAll ? filtered : filtered.slice(0, PREVIEW_COUNT)
|
||||
const hiddenCount = filtered.length - visible.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Category filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(cat.id)
|
||||
setShowAll(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filtered.map(tpl => {
|
||||
{visible.map(tpl => {
|
||||
const Icon = templateIcons[tpl.id as TemplateId] ?? typeIcons[tpl.type] ?? Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const nameKey = templateNameKey(tpl.id)
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground mb-4">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
|
||||
{tpl.frequency !== 'manual' && (
|
||||
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0">
|
||||
{tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'}
|
||||
{t(`agents.frequencies.${tpl.frequency}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 disabled:opacity-50"
|
||||
@@ -211,6 +231,25 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.seeAll')}
|
||||
</button>
|
||||
)}
|
||||
{showAll && filtered.length > PREVIEW_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(false)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.showLess')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,26 +16,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Provider display info ────────────────────────────────────────────────────
|
||||
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
||||
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
|
||||
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
|
||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
||||
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
|
||||
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
|
||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
|
||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
|
||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
|
||||
custom: { name: 'Custom API', hint: 'Your own endpoint' },
|
||||
}
|
||||
import { providerDisplayName } from '@/lib/ai/provider-labels'
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
|
||||
|
||||
function displayName(provider: string): string {
|
||||
return PROVIDER_INFO[provider]?.name ?? provider
|
||||
return providerDisplayName(provider)
|
||||
}
|
||||
|
||||
function providerHint(provider: string): string {
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []
|
||||
return models.slice(0, 2).join(', ')
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
|
||||
@@ -155,20 +145,20 @@ function EditKeyForm({
|
||||
const showModelInput = manualModel || (!loadingModels && models.length === 0)
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
||||
<div className="mt-2 border border-brand-accent/20 rounded-xl bg-brand-accent/5 p-4 space-y-3">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">
|
||||
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
|
||||
</p>
|
||||
|
||||
{/* Alias */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
|
||||
</div>
|
||||
{needsUrl && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +166,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{showModelDropdown ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
|
||||
@@ -189,7 +179,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Key rotation (optional) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">
|
||||
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -205,7 +195,7 @@ function EditKeyForm({
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] border',
|
||||
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
|
||||
)}>
|
||||
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||
@@ -224,7 +214,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={!newKey || newKey.length < 8 || testing}
|
||||
onClick={testModel}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-brand-accent/50 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||
{t('byok.test')}
|
||||
@@ -233,7 +223,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.12em] bg-brand-accent text-white shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
|
||||
</Button>
|
||||
@@ -286,13 +276,6 @@ export function ByokSettingsPanel() {
|
||||
setTestResult(null)
|
||||
setModel('')
|
||||
setModels([])
|
||||
if (MANUAL_MODEL_PROVIDERS.has(p)) return
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const list = await fetchModelsFromServer(p)
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0])
|
||||
} finally { setLoadingModels(false) }
|
||||
}
|
||||
|
||||
async function refreshModels(p: string, key: string, _baseUrl?: string) {
|
||||
@@ -403,10 +386,10 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
|
||||
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20"><KeyRound size={18} /></div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
<p className="text-sm text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,7 +402,7 @@ export function ByokSettingsPanel() {
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||
)}>
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[13px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
|
||||
)}
|
||||
@@ -433,7 +416,7 @@ export function ByokSettingsPanel() {
|
||||
{/* Saved keys */}
|
||||
{keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">{t('byok.savedKeys')}</p>
|
||||
<ul className="space-y-1">
|
||||
{keys.map((key) => (
|
||||
<li key={key.provider}>
|
||||
@@ -447,9 +430,9 @@ export function ByokSettingsPanel() {
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.model && <code className="text-[13px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[13px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[13px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -462,7 +445,7 @@ export function ByokSettingsPanel() {
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
|
||||
editingProvider === key.provider
|
||||
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
|
||||
? 'text-brand-accent bg-brand-accent/10 border border-brand-accent/30'
|
||||
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
|
||||
@@ -492,7 +475,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Inline edit form */}
|
||||
{editingProvider === key.provider && (
|
||||
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<div className="border border-brand-accent/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<EditKeyForm
|
||||
savedKey={key}
|
||||
onDone={() => setEditingProvider(null)}
|
||||
@@ -508,40 +491,40 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Add key form */}
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
||||
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Label htmlFor="byok-provider" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
||||
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
<span className="font-medium">{displayName(p)}</span>
|
||||
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
|
||||
{providerHint(p) && <span className="ml-2 text-[13px] text-concrete">{providerHint(p)}</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Label htmlFor="byok-alias" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{NEEDS_BASE_URL.has(provider) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor="byok-baseurl" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
|
||||
<Label htmlFor="byok-key" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -554,8 +537,8 @@ export function ByokSettingsPanel() {
|
||||
</div>
|
||||
<button
|
||||
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-brand-accent/50')}
|
||||
>
|
||||
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
|
||||
</button>
|
||||
@@ -564,7 +547,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{provider && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor="byok-model" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
|
||||
) : showModelDropdown ? (
|
||||
@@ -593,13 +576,13 @@ export function ByokSettingsPanel() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-brand-accent/50 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
|
||||
</button>
|
||||
<Button
|
||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
className="flex-1 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.15em] transition-all duration-200 bg-brand-accent text-white shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
@@ -23,6 +25,7 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
|
||||
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
|
||||
|
||||
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
|
||||
const fresh = await getNoteById(note.id)
|
||||
@@ -50,17 +53,24 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleDeleteNote = useCallback(async (note: Note) => {
|
||||
const handleDeleteNote = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNote = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
setNotes((prev) => prev.filter((n) => n.id !== note.id))
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [t])
|
||||
}, [notePendingDelete, t])
|
||||
|
||||
if (editingNote) {
|
||||
return (
|
||||
@@ -78,11 +88,20 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<>
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotePendingDelete(null)
|
||||
}}
|
||||
onConfirm={confirmDeleteNote}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
52
memento-note/components/auth-shell.tsx
Normal file
52
memento-note/components/auth-shell.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
function AuthHeader() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('general.back')}
|
||||
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
||||
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
|
||||
<AuthHeader />
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2026 Memento
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ConfirmDeleteNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('notes.confirmDelete')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void onConfirm() }}>
|
||||
{t('notes.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -86,18 +86,17 @@ export function DashboardActionStrip({
|
||||
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
|
||||
const motionProps = item.pulse && !prefersReducedMotion
|
||||
? {
|
||||
key: `inbox-pulse-${inboxPulse}`,
|
||||
animate: { scale: [1, 1.03, 1] },
|
||||
transition: { duration: 0.45 },
|
||||
}
|
||||
: {}
|
||||
return (
|
||||
<Wrapper
|
||||
key={item.key}
|
||||
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
{...motionProps}
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[128px] ${
|
||||
item.accent
|
||||
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
|
||||
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
|
||||
@@ -114,7 +113,7 @@ export function DashboardActionStrip({
|
||||
}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
|
||||
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2, Check } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface AgentSuggestion {
|
||||
suggestedFrequency: string
|
||||
}
|
||||
|
||||
export interface CreatedAgentNotice {
|
||||
id: string | null
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface DashboardAgentCarouselProps {
|
||||
suggestions: AgentSuggestion[]
|
||||
loading?: boolean
|
||||
@@ -21,6 +26,9 @@ export interface DashboardAgentCarouselProps {
|
||||
formatFrequency: (f: string) => string
|
||||
onAccept: (id: string) => void
|
||||
onDismiss: (id: string) => void
|
||||
createdAgent?: CreatedAgentNotice | null
|
||||
onOpenCreated?: () => void
|
||||
onClearCreated?: () => void
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
@@ -31,29 +39,46 @@ export function DashboardAgentCarousel({
|
||||
formatFrequency,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
createdAgent,
|
||||
onOpenCreated,
|
||||
onClearCreated,
|
||||
prefersReducedMotion,
|
||||
}: DashboardAgentCarouselProps) {
|
||||
const { t } = useLanguage()
|
||||
const [idx, setIdx] = useState(0)
|
||||
const directionRef = useRef(1)
|
||||
const safeIdx = suggestions.length === 0 ? 0 : Math.min(idx, suggestions.length - 1)
|
||||
|
||||
const navActions = suggestions.length > 1 ? (
|
||||
const goPrev = () => {
|
||||
directionRef.current = -1
|
||||
setIdx(i => Math.max(0, i - 1))
|
||||
}
|
||||
const goNext = () => {
|
||||
directionRef.current = 1
|
||||
setIdx(i => Math.min(suggestions.length - 1, i + 1))
|
||||
}
|
||||
|
||||
const showCreated = !!createdAgent
|
||||
const showNav = !showCreated && suggestions.length > 1
|
||||
|
||||
const navActions = showNav ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx(i => Math.max(0, i - 1))}
|
||||
disabled={idx === 0}
|
||||
onClick={goPrev}
|
||||
disabled={safeIdx === 0}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelPrev')}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[8px] font-mono text-concrete px-1">
|
||||
{idx + 1}/{suggestions.length}
|
||||
{safeIdx + 1}/{suggestions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
|
||||
disabled={idx >= suggestions.length - 1}
|
||||
onClick={goNext}
|
||||
disabled={safeIdx >= suggestions.length - 1}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelNext')}
|
||||
>
|
||||
@@ -73,9 +98,18 @@ export function DashboardAgentCarousel({
|
||||
icon={<Bot size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.suggestedResearch')}
|
||||
actions={navActions}
|
||||
wrapTitle
|
||||
/>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
{showCreated && createdAgent ? (
|
||||
<CreatedAgentPanel
|
||||
topic={createdAgent.topic}
|
||||
hasMore={suggestions.length > 0}
|
||||
onOpen={onOpenCreated}
|
||||
onSeeNext={onClearCreated}
|
||||
t={t}
|
||||
/>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
||||
<p className="text-[11px] text-concrete leading-relaxed">
|
||||
{t('homeDashboard.agentsEmpty')}
|
||||
@@ -83,7 +117,8 @@ export function DashboardAgentCarousel({
|
||||
</div>
|
||||
) : (
|
||||
<AgentSlide
|
||||
current={suggestions[idx]}
|
||||
current={suggestions[safeIdx]}
|
||||
direction={directionRef.current}
|
||||
actingId={actingId}
|
||||
formatFrequency={formatFrequency}
|
||||
onAccept={onAccept}
|
||||
@@ -96,8 +131,57 @@ export function DashboardAgentCarousel({
|
||||
)
|
||||
}
|
||||
|
||||
function CreatedAgentPanel({
|
||||
topic,
|
||||
hasMore,
|
||||
onOpen,
|
||||
onSeeNext,
|
||||
t,
|
||||
}: {
|
||||
topic: string
|
||||
hasMore: boolean
|
||||
onOpen?: () => void
|
||||
onSeeNext?: () => void
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<Check size={14} className="text-brand-accent shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">
|
||||
{t('homeDashboard.agentCreatedInCard')}
|
||||
</p>
|
||||
{topic ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed mt-1">{topic}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{hasMore && onSeeNext ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeNext}
|
||||
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors"
|
||||
>
|
||||
{t('homeDashboard.agentSeeNextSuggestion')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90"
|
||||
>
|
||||
{t('homeDashboard.agentOpenCreated')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSlide({
|
||||
current,
|
||||
direction,
|
||||
actingId,
|
||||
formatFrequency,
|
||||
onAccept,
|
||||
@@ -106,6 +190,7 @@ function AgentSlide({
|
||||
t,
|
||||
}: {
|
||||
current: AgentSuggestion
|
||||
direction: number
|
||||
actingId: string | null
|
||||
formatFrequency: (f: string) => string
|
||||
onAccept: (id: string) => void
|
||||
@@ -115,7 +200,11 @@ function AgentSlide({
|
||||
}) {
|
||||
const slide = prefersReducedMotion
|
||||
? { initial: {}, animate: {}, exit: {} }
|
||||
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
|
||||
: {
|
||||
initial: { opacity: 0, x: 14 * direction },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -14 * direction },
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -148,7 +237,7 @@ function AgentSlide({
|
||||
type="button"
|
||||
disabled={actingId === current.id}
|
||||
onClick={() => onAccept(current.id)}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
|
||||
{t('homeDashboard.createAgent')}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||
import { UsageMeter } from '@/components/usage-meter'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import { pathNoteTitle } from '@/lib/dashboard/path-title'
|
||||
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||
|
||||
interface DashboardWidgetShellProps {
|
||||
@@ -40,16 +42,40 @@ export function DashboardWidgetShell({
|
||||
)
|
||||
}
|
||||
|
||||
function inboxDisplayTitle(
|
||||
note: { title: string | null; excerpt?: string },
|
||||
fallback: string,
|
||||
): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardInboxWidget({
|
||||
count,
|
||||
notes,
|
||||
loading,
|
||||
onOpen,
|
||||
onSelect,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
count: number
|
||||
notes: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
}>
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
formatRelativeTime?: (date: string) => string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const reduced = !!useReducedMotion()
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="inbox"
|
||||
@@ -58,19 +84,43 @@ export function DashboardInboxWidget({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||
</button>
|
||||
<div className="space-y-1.5">
|
||||
{notes.map((note, idx) => (
|
||||
<motion.button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
initial={reduced ? false : { opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
|
||||
>
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
|
||||
{inboxDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{t('homeDashboard.inbox')}
|
||||
{note.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(note.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</motion.button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.inboxOpenList')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
@@ -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 => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||
>
|
||||
{n.title || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
{notes.map(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.excerpt) || untitled
|
||||
const notebookName = n.notebook?.name || t('homeDashboard.pinnedNoNotebook')
|
||||
const notebookColor = n.notebook?.color || '#C4A574'
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full flex items-center gap-2.5 p-2 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1 h-7 rounded-full shrink-0"
|
||||
style={{ backgroundColor: notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{notebookName}
|
||||
{n.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(n.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -309,8 +389,7 @@ export function DashboardActivityWidget({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RevisionHeatmap data={data} />
|
||||
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||
<RevisionHeatmap data={data} kind="edits" />
|
||||
</>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -397,15 +476,12 @@ export function DashboardFlashcardsProgressWidget({
|
||||
style={{ width: `${retention}%` }}
|
||||
/>
|
||||
</div>
|
||||
{dueCount > 0 ? (
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{t('homeDashboard.flashDueCta', { count: dueCount })} →
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
|
||||
{t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[9px] text-concrete leading-relaxed mb-2">
|
||||
{t('homeDashboard.flashRetentionHint')}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{dueCount > 0 ? t('homeDashboard.flashDueCta') : t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
|
||||
@@ -19,12 +19,22 @@ interface BridgeNote {
|
||||
}
|
||||
|
||||
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||
const EASE = [0.16, 1, 0.3, 1] as const
|
||||
|
||||
function clusterPoint(index: number, count: number): { x: number; y: number } {
|
||||
if (count === 1) return { x: 50, y: 42 }
|
||||
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
|
||||
const rx = count <= 3 ? 28 : 36
|
||||
const ry = count <= 3 ? 24 : 30
|
||||
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
|
||||
}
|
||||
|
||||
export interface DashboardMindOrbitProps {
|
||||
clusters: Cluster[]
|
||||
bridgeNotes: BridgeNote[]
|
||||
loading?: boolean
|
||||
onOpenInsights: () => void
|
||||
onOpenCluster?: (clusterId: number) => void
|
||||
onNoteSelect: (id: string) => void
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
|
||||
bridgeNotes,
|
||||
loading,
|
||||
onOpenInsights,
|
||||
onOpenCluster,
|
||||
onNoteSelect,
|
||||
prefersReducedMotion,
|
||||
}: DashboardMindOrbitProps) {
|
||||
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
|
||||
.slice(0, 5)
|
||||
const maxCount = topClusters[0]?.noteIds.length || 1
|
||||
const topBridge = bridgeNotes[0]
|
||||
const reduced = !!prefersReducedMotion
|
||||
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
@@ -80,21 +93,60 @@ export function DashboardMindOrbit({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
|
||||
<motion.div className="relative h-[200px] overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
aria-hidden
|
||||
>
|
||||
{points.map((point, idx) => {
|
||||
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
|
||||
return (
|
||||
<motion.path
|
||||
key={`spoke-${topClusters[idx].clusterId}`}
|
||||
d={`M 50 44 L ${point.x} ${point.y}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={0.7}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 0.4 }}
|
||||
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{topClusters.map((cluster, idx) => {
|
||||
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
||||
const size = Math.round(56 * scale)
|
||||
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||
const size = Math.round(52 * scale)
|
||||
const label = cluster.name?.trim() || t('homeDashboard.theme')
|
||||
const point = points[idx]
|
||||
return (
|
||||
<motion.button
|
||||
key={cluster.clusterId}
|
||||
type="button"
|
||||
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||
onClick={onOpenInsights}
|
||||
className="relative flex flex-col items-center gap-1.5 group"
|
||||
style={{ width: size + 16 }}
|
||||
whileHover={reduced ? undefined : { scale: 1.06 }}
|
||||
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
|
||||
className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
|
||||
aria-label={label}
|
||||
style={{
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`,
|
||||
width: Math.max(size + 24, 128),
|
||||
}}
|
||||
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
|
||||
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
|
||||
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
||||
@@ -108,19 +160,25 @@ export function DashboardMindOrbit({
|
||||
>
|
||||
{cluster.noteIds.length}
|
||||
</div>
|
||||
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
|
||||
<span className="text-[9px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[10px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
|
||||
{label}
|
||||
</span>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{topBridge?.note && (
|
||||
<button
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onNoteSelect(topBridge.noteId)}
|
||||
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
||||
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
|
||||
>
|
||||
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -132,7 +190,7 @@ export function DashboardMindOrbit({
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{Math.round(topBridge.bridgeScore * 100)}%
|
||||
</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,16 +12,16 @@ import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row
|
||||
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||
|
||||
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
|
||||
continue: { Icon: PenLine, accent: 'text-ink' },
|
||||
continue: { Icon: PenLine, accent: 'text-brand-accent' },
|
||||
connect: { Icon: Link2, accent: 'text-brand-accent' },
|
||||
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
|
||||
research: { Icon: Sparkles, accent: 'text-sky-600' },
|
||||
explore: { Icon: Compass, accent: 'text-amber-600' },
|
||||
'add-link': { Icon: Plus, accent: 'text-brand-accent' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-brand-accent' },
|
||||
research: { Icon: Sparkles, accent: 'text-brand-accent' },
|
||||
explore: { Icon: Compass, accent: 'text-brand-accent' },
|
||||
organize: { Icon: Inbox, accent: 'text-brand-accent' },
|
||||
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
|
||||
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
|
||||
daily: { Icon: BookOpen, accent: 'text-concrete' },
|
||||
daily: { Icon: BookOpen, accent: 'text-brand-accent' },
|
||||
}
|
||||
|
||||
export interface DashboardNextPathsProps {
|
||||
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
|
||||
onAction,
|
||||
prefersReducedMotion,
|
||||
}: DashboardNextPathsProps) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const rtl = language === 'ar' || language === 'fa'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
|
||||
const hero = paths[0]
|
||||
const rest = paths.slice(1, 5)
|
||||
const HeroIcon = TYPE_META[hero.type].Icon
|
||||
const reduced = !!prefersReducedMotion
|
||||
const ease = [0.16, 1, 0.3, 1] as const
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onAction(hero)}
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
whileHover={reduced ? undefined : { y: -1 }}
|
||||
key={hero.id}
|
||||
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
|
||||
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
|
||||
transition={{ duration: reduced ? 0 : 0.48, ease }}
|
||||
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="divide-y divide-border/10">
|
||||
{rest.map(path => {
|
||||
{rest.map((path, idx) => {
|
||||
const meta = TYPE_META[path.type]
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
key={path.id}
|
||||
type="button"
|
||||
onClick={() => onAction(path)}
|
||||
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
|
||||
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
||||
>
|
||||
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
||||
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
|
||||
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
||||
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
@@ -24,27 +23,33 @@ export interface DashboardResumeHeroProps {
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
function resumeDisplayTitle(note: ResumeNote, fallback: string): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardResumeHero({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
onCaptureFocus,
|
||||
formatRelativeTime,
|
||||
prefersReducedMotion,
|
||||
}: DashboardResumeHeroProps) {
|
||||
const { t } = useLanguage()
|
||||
const hero = notes[0]
|
||||
const rest = notes.slice(1, 6)
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
const visible = notes.slice(0, 6)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[180px] animate-pulse">
|
||||
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
|
||||
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +66,7 @@ export function DashboardResumeHero({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hero ? (
|
||||
{visible.length === 0 ? (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
|
||||
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
|
||||
@@ -81,73 +86,33 @@ export function DashboardResumeHero({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
onClick={() => onSelect(hero.id, hero.notebookId)}
|
||||
className="w-full text-start px-5 pb-3 group cursor-pointer"
|
||||
>
|
||||
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<span
|
||||
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
|
||||
style={{ backgroundColor: hero.notebookColor }}
|
||||
>
|
||||
{hero.notebookName}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
|
||||
{formatRelativeTime(hero.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
|
||||
{hero.title || t('homeDashboard.untitled')}
|
||||
</h3>
|
||||
{hero.excerpt ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
|
||||
{hero.excerpt}
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
{visible.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{resumeDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
{t('homeDashboard.resumeOpen')}
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
|
||||
{t('homeDashboard.resumeAlso')}
|
||||
</p>
|
||||
{rest.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.title || t('homeDashboard.untitled')}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,12 @@ interface EmotionMeta {
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface SentimentRelatedNote {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
export interface DashboardSentimentChipProps {
|
||||
available: boolean
|
||||
loading?: boolean
|
||||
@@ -17,6 +23,8 @@ export interface DashboardSentimentChipProps {
|
||||
summary?: string
|
||||
emotions?: Record<string, number>
|
||||
emotionMeta: Record<string, EmotionMeta>
|
||||
relatedNotes?: SentimentRelatedNote[]
|
||||
onSelectNote?: (id: string, notebookId: string | null) => void
|
||||
}
|
||||
|
||||
export function DashboardSentimentChip({
|
||||
@@ -26,6 +34,8 @@ export function DashboardSentimentChip({
|
||||
summary,
|
||||
emotions,
|
||||
emotionMeta,
|
||||
relatedNotes = [],
|
||||
onSelectNote,
|
||||
}: DashboardSentimentChipProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -80,6 +90,24 @@ export function DashboardSentimentChip({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{relatedNotes.length > 0 && onSelectNote && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70">
|
||||
{t('homeDashboard.sentimentFromNotes')}
|
||||
</p>
|
||||
{relatedNotes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelectNote(note.id, note.notebookId)}
|
||||
className="w-full text-start text-[11px] text-ink dark:text-dark-ink truncate px-2 py-1.5 rounded-lg hover:bg-brand-accent/[0.04] hover:text-brand-accent transition-colors"
|
||||
>
|
||||
{note.title?.trim() || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emotions && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{Object.entries(emotions)
|
||||
|
||||
@@ -3,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 => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
})), [recentNotes, t])
|
||||
|
||||
const briefingSubtitle = useMemo(() => {
|
||||
if (briefingLoading) return ''
|
||||
const parts: string[] = []
|
||||
if (inboxCount > 0) parts.push(t('homeDashboard.pulseInbox', { count: inboxCount }))
|
||||
if (dueFlashcards > 0) parts.push(t('homeDashboard.pulseReview', { count: dueFlashcards }))
|
||||
if (discoveryCount > 0) parts.push(t('homeDashboard.pulseDiscoveries', { count: discoveryCount }))
|
||||
if (parts.length === 0) return t('homeDashboard.pulseClear')
|
||||
return parts.join(' · ')
|
||||
}, [briefingLoading, inboxCount, dueFlashcards, discoveryCount, t])
|
||||
const resumeNotes = useMemo(() => recentNotes.flatMap(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.content)
|
||||
if (!displayTitle) return []
|
||||
return [{
|
||||
id: n.id,
|
||||
title: displayTitle,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
}]
|
||||
}), [recentNotes, t])
|
||||
|
||||
const handleCapture = useCallback(async () => {
|
||||
const text = captureText.trim()
|
||||
@@ -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">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<PenLine size={11} className="text-brand-accent shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete block">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<span className="text-[9px] text-concrete leading-tight">
|
||||
{t('homeDashboard.captureGoesToFile')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardWidgetHelp widgetId="capture" />
|
||||
</div>
|
||||
@@ -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,33 +955,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.remindersOpenAll')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!briefingLoading && (
|
||||
gmail?.connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start mt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Mail size={11} className="text-concrete" />
|
||||
<span className="text-[10px] text-ink dark:text-dark-ink truncate">{t('homeDashboard.gmailCaptures')}</span>
|
||||
</div>
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">
|
||||
{t('homeDashboard.gmailRecent', { count: gmail.recentCaptures })}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full text-[9px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2 mt-2"
|
||||
>
|
||||
{t('homeDashboard.gmailConnect')} →
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>,
|
||||
)
|
||||
case 'mind-map':
|
||||
@@ -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,20 +1109,13 @@ 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>
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
{!briefingLoading && briefingSubtitle && (
|
||||
<p className="text-[11px] text-concrete leading-relaxed max-w-md sm:text-end">
|
||||
{briefingSubtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="pb-4 border-b border-border/20">
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pulse : file d'attente cognitive en un coup d'œil */}
|
||||
@@ -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>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { toast } from 'sonner'
|
||||
|
||||
interface DashboardWidgetGridProps {
|
||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
|
||||
}
|
||||
|
||||
function SortableWidget({
|
||||
@@ -139,7 +140,7 @@ function ZoneColumn({
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
||||
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
|
||||
const { t } = useLanguage()
|
||||
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
@@ -228,15 +229,30 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
}, [persistLayout, t])
|
||||
|
||||
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const hidden = hiddenWidgetIds(layout)
|
||||
const catalog = catalogByCategory(layout)
|
||||
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className={editMode ? 'space-y-4 pb-24' : 'space-y-4'}>
|
||||
{!editMode && loaded && hasVisibleWidgets && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-1.5 rounded-xl text-brand-accent hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loaded ? (
|
||||
hasVisibleWidgets ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
@@ -307,9 +323,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(v => !v)}
|
||||
@@ -337,18 +352,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
<Check size={12} />
|
||||
{t('homeDashboard.widgetDone')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode && catalogOpen && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DashboardWidgetTitleRowProps {
|
||||
title: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
wrapTitle?: boolean
|
||||
}
|
||||
|
||||
/** Titre widget : actions d’abord, aide « ? » en dernier — ne masque jamais la navigation. */
|
||||
@@ -19,12 +20,15 @@ export function DashboardWidgetTitleRow({
|
||||
title,
|
||||
actions,
|
||||
className = 'mb-3',
|
||||
wrapTitle,
|
||||
}: DashboardWidgetTitleRowProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{icon}
|
||||
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
|
||||
<div className={`flex items-start justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
{icon ? <span className="shrink-0 mt-0.5">{icon}</span> : null}
|
||||
<h2 className={`text-[13px] font-semibold uppercase tracking-wider text-ink dark:text-dark-ink ${
|
||||
wrapTitle ? 'leading-tight whitespace-normal' : 'truncate'
|
||||
}`}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[removeNoteFromList, t]
|
||||
)
|
||||
const handleDeleteNoteFromList = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNoteFromList = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [notePendingDelete, removeNoteFromList, t])
|
||||
|
||||
const handleArchiveNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal file
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
971
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal file
971
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal file
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 l’URL 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
391
memento-note/components/interactive-page/page-blocks.tsx
Normal file
391
memento-note/components/interactive-page/page-blocks.tsx
Normal 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 }
|
||||
92
memento-note/components/interactive-page/page-md.tsx
Normal file
92
memento-note/components/interactive-page/page-md.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
155
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
282
memento-note/components/interactive-page/page-view.tsx
Normal file
282
memento-note/components/interactive-page/page-view.tsx
Normal 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')
|
||||
? 'L’essentiel 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>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/interactive-page/sim-block.tsx
Normal file
104
memento-note/components/interactive-page/sim-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
134
memento-note/components/interactive-page/steps-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">"provider": "anthropic",</p>
|
||||
<p className="pl-3">"model": "claude-sonnet",</p>
|
||||
<p className="pl-3 text-[#F4F1EA]/70">"apiKey": "sk-ant-…",</p>
|
||||
<p className="pl-3">"yours": 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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 d’insé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 à l’ouverture 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 d’après la note', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc lié', description: t('richTextEditor.slashLivingBlockDesc') || 'Ouvrir un passage d’une 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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 +457,11 @@ 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'
|
||||
? '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'
|
||||
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'
|
||||
)}>
|
||||
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
||||
</span>
|
||||
@@ -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,39 +489,61 @@ 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">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToPlans}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-brand-accent text-white rounded-xl text-xs font-semibold hover:opacity-90 transition-all shadow-md shadow-brand-accent/20"
|
||||
>
|
||||
{t('billing.changeOffer')}
|
||||
</button>
|
||||
|
||||
{isPaid && !status?.cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('billing.cancelSubscription')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPaid && status?.hasStripeSubscription && (
|
||||
<button
|
||||
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"
|
||||
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') || 'Gérer la facturation'}
|
||||
{t('billing.manageBilling')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
|
||||
</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">
|
||||
@@ -755,8 +832,7 @@ export function BillingPlans() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
|
||||
31
memento-note/components/settings/settings-document-title.tsx
Normal file
31
memento-note/components/settings/settings-document-title.tsx
Normal 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
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
{[
|
||||
|
||||
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
231
memento-note/components/simulators/anim-player-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal file
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal 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', 'η']
|
||||
|
||||
// P–V 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>
|
||||
|
||||
{/* ══ P–V diagram ══ */}
|
||||
<g>
|
||||
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
|
||||
P–V
|
||||
</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>
|
||||
)
|
||||
}
|
||||
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal file
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
memento-note/components/simulators/generic-formula-view.tsx
Normal file
207
memento-note/components/simulators/generic-formula-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
memento-note/components/simulators/index.ts
Normal file
27
memento-note/components/simulators/index.ts
Normal 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,
|
||||
}
|
||||
144
memento-note/components/simulators/sim-controls.tsx
Normal file
144
memento-note/components/simulators/sim-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
|
||||
/**
|
||||
* T–s 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 T–s du cycle de Carnot' : 'T–s 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
Reference in New Issue
Block a user