Compare commits
18 Commits
fix/bugs-b
...
80ccc1f6de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ccc1f6de | ||
|
|
69c99e4f4f | ||
|
|
f385d43d5d | ||
|
|
324bf40658 | ||
|
|
86505659cf | ||
|
|
4946c0d44a | ||
|
|
0ee2afbd62 | ||
|
|
bff060ad20 | ||
|
|
1987b084c3 | ||
|
|
07ac42cbee | ||
|
|
1e9b1b30c9 | ||
|
|
d5c44aafb6 | ||
|
|
9d10815415 | ||
|
|
cf3b5483d2 | ||
|
|
6eb46fd1b7 | ||
|
|
a7c15e9fc3 | ||
|
|
88a7d2ad0a | ||
|
|
e8ee53c815 |
45
.impeccable/config.json
Normal file
45
.impeccable/config.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"buildPath": "comp",
|
||||||
|
"detector": {
|
||||||
|
"ignoreRules": [],
|
||||||
|
"ignoreFiles": [],
|
||||||
|
"ignoreValues": [
|
||||||
|
{
|
||||||
|
"rule": "ai-color-palette",
|
||||||
|
"value": "*",
|
||||||
|
"files": [
|
||||||
|
"memento-note/components/intelligence-hub.tsx"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-08-26T18:03:40.105Z",
|
||||||
|
"reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "side-tab",
|
||||||
|
"value": "*",
|
||||||
|
"files": [
|
||||||
|
"memento-note/components/note-editor/note-editor-toolbar.tsx"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-08-26T18:14:54.764Z",
|
||||||
|
"reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "border-accent-on-rounded",
|
||||||
|
"value": "*",
|
||||||
|
"files": [
|
||||||
|
"memento-note/app/(main)/insights/page.tsx"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-08-26T18:23:06.241Z",
|
||||||
|
"reason": "Styles existants de /insights (incumbent), pas une nouvelle carte."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "gray-on-color",
|
||||||
|
"value": "*",
|
||||||
|
"files": [
|
||||||
|
"memento-note/app/(main)/insights/page.tsx"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-08-26T18:23:06.270Z",
|
||||||
|
"reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
AGENTS.md
16
AGENTS.md
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## Learned User Preferences
|
## Learned User Preferences
|
||||||
|
|
||||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
|
- Préfère les échanges en **français correct** (pas d'anglais par défaut — l'utilisateur n'est pas à l'aise pour corriger en anglais), avec des explications détaillées et claires (éviter le jargon flou) ; **rester sur le sujet demandé** (ne pas digresser vers git/ops hors scope quand la demande porte sur une feature métier) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
|
||||||
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
|
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **jamais de marque tierce** dans les libellés utilisateur (ex. référence design « Kimi » → UI/i18n = « Page interactive » / « Interactive page ») ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
|
||||||
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
|
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
|
||||||
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
|
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
|
||||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
||||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
|
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; inscription **email/mdp** : **email de confirmation obligatoire** (`lib/auth/email-verification.ts`, envoi à l'inscription) — login bloqué tant que `emailVerified` est null ; OAuth Google marque l'email comme vérifié automatiquement ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
|
||||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **Interactive Demo** (player TipTap) : sélecteur de vitesse **visible** (`0.5x` / `1x` / `2x` / `4x` — pas un bouton cycle cryptique) ; scènes **dignes du contenu** (pas de boîtes jouet 2 nœuds / texte plat) — labels et équations issus de la note (TipTap math `data-latex` / KaTeX) ; génération IA via le **modèle slides** (`getSlidesProvider`), **pas** la lane chat ; **Page interactive** (explainer publié, **≠** Interactive Demo TipTap) : pipeline note → PageSpec → `/p/{slug}` ; priorité **vitesse/fiabilité** de génération (chemin **déterministe** `buildPageFromNote` / démo déterministe plutôt que LLM lent qui timeoute ~150 s vs abort client) ; simulateurs (ex. cycle de Carnot) : flèches correctement orientées, têtes SVG non déformées, flux branchés sur la machine (pas de flèches flottantes) ; unités température commutables **K / °C / °F** ; distinguer **énergie (kJ)** et **puissance (W)** ; modes **frigo / PAC / moteur** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents, menu `/` éditeur…) — pas un slideshow landing seul ; démos note/éditeur : **masquer sidebar/carnets** pour focaliser la note ; style kinetic OK (coupes rapides + texte à l'écran) mais **pas de fade-to-black** entre plans (clignote) — **crossfades** ; après saisie, montrer le **sparkle** titre IA ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
|
||||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
|
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
|
||||||
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **essai gratuit abonnement = 7 jours** (`SUBSCRIPTION_TRIAL_DAYS` dans `lib/billing/trial-constants.ts` — pas 14) : afficher clairement sur **landing** et UI facturation (**i18n partout**) ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
||||||
|
|
||||||
## Learned Workspace Facts
|
## Learned Workspace Facts
|
||||||
|
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
- i18n : 15 fichiers sous `memento-note/locales/` (de, en, es, fr, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) ; logique sous `memento-note/lib/i18n/` ; référence `en.json` (~2218 clés) ; auditer les « non traduits » par flatten EN vs locale (souvent valeurs identiques à l'EN).
|
- i18n : 15 fichiers sous `memento-note/locales/` (de, en, es, fr, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) ; logique sous `memento-note/lib/i18n/` ; référence `en.json` (~2218 clés) ; auditer les « non traduits » par flatten EN vs locale (souvent valeurs identiques à l'EN).
|
||||||
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
|
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
|
||||||
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
|
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
|
||||||
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA et config Stripe métier en base, effet ~60 s ; guide `memento-note/docs/admin-billing-quotas-guide.md`.
|
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA, config Stripe métier en base (effet ~60 s), santé/stats ops ; essai **7 jours** via `lib/billing/trial-constants.ts` ; guide `memento-note/docs/admin-billing-quotas-guide.md` ; settings user `/settings/billing` (`billing-plans.tsx`).
|
||||||
- **Hosts** : machine agent/dev (**192.168.1.83**, Vibe/annotations locales) ≠ prod (**192.168.1.190**, dépôt `/opt/memento`) — ne jamais diagnostiquer l'un pour l'autre. Production : conteneur `memento-note` port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; auth prod requiert `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `.env.docker` (sinon OAuth `invalid_client`) et `ALLOW_REGISTRATION=true` pour l'inscription email ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; monitoring Grafana via `GRAFANA_BIND` / `GRAFANA_URL` dans `.env.docker` (pas d'IP en dur dans le compose) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
|
- **Hosts** : machine agent/dev (**192.168.1.83**, Vibe/annotations locales) ≠ prod (**192.168.1.190**, dépôt `/opt/memento`) — ne jamais diagnostiquer l'un pour l'autre. Production : conteneur `memento-note` port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; auth prod requiert `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `.env.docker` (sinon OAuth `invalid_client`) et `ALLOW_REGISTRATION=true` pour l'inscription email ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; monitoring Grafana via `GRAFANA_BIND` / `GRAFANA_URL` dans `.env.docker` (pas d'IP en dur dans le compose) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
|
||||||
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
||||||
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
||||||
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **Interactive Demo** : JSON déclaratif allowlisté + player TipTap (`lib/interactive-demo/` — `validate.ts` / `resolve.ts` / schéma ; `components/interactive-demo/` ; extension `tiptap-interactive-demo-extension` ; API `/api/ai/interactive-demo` via `getSlidesProvider` + extraction formules type slides, labels KaTeX) — le player **consomme** `resolve.ts` (pas de logique de portée dans React) ; **Page interactive** (explainer) : `lib/interactive-page/` (PageSpecV1, schema/validate/normalize, fixtures thermo/Carnot) + `components/interactive-page/` (`page-view`, `sim-block`, publish dialog) + API `/api/ai/interactive-page` ; preview `/dev/interactive-page` ; publication publique `/p/{slug}` ; **simulateur Carnot** : `lib/simulators/carnot-cycle*.ts` + `components/simulators/carnot-cycle*.tsx` (modes frigo/PAC/moteur, unités K/°C/°F, énergie kJ vs puissance W) ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
||||||
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
||||||
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage.
|
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `make_promo_slash.py`, `make_promo_slash_kinetic.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom (crossfades si kinetic).
|
||||||
|
|||||||
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.
|
- **`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.
|
- **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.created`
|
||||||
- `customer.subscription.updated`
|
- `customer.subscription.updated`
|
||||||
- `customer.subscription.deleted`
|
- `customer.subscription.deleted`
|
||||||
|
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin d’essai)
|
||||||
- `invoice.payment_failed`
|
- `invoice.payment_failed`
|
||||||
4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
|
4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
|
||||||
|
|
||||||
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
|
|||||||
|
|
||||||
| Événement Stripe | Action Memento | Statut Prisma |
|
| Événement Stripe | Action Memento | Statut Prisma |
|
||||||
|------------------|---------------|---------------|
|
|------------------|---------------|---------------|
|
||||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
|
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
|
||||||
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
||||||
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
||||||
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
||||||
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
|
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
|
||||||
|
| `customer.subscription.trial_will_end` | Sync + email rappel fin d’essai | `TRIALING` |
|
||||||
|
|
||||||
### 3.3 Mapping statuts Stripe → Prisma
|
### 3.3 Mapping statuts Stripe → Prisma
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ **22 Tools** for notes, notebooks, labels, and reminders
|
- ✅ **29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
|
||||||
- 🔒 **API Key Authentication** with secure storage
|
- 🔒 **API Key Authentication** with secure storage
|
||||||
- 🚀 **Performance Optimized** with connection pooling and caching
|
- 🚀 **Performance Optimized** with connection pooling and caching
|
||||||
- 📊 **Observability** with Prometheus metrics export
|
- 📊 **Observability** with Prometheus metrics export
|
||||||
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
|
|||||||
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Tools (22)
|
## Available Tools (29)
|
||||||
|
|
||||||
### Notes (13)
|
### Notes (13)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
|||||||
| `batch_move_notes` | Move multiple notes at once |
|
| `batch_move_notes` | Move multiple notes at once |
|
||||||
| `batch_delete_notes` | Delete multiple notes at once |
|
| `batch_delete_notes` | Delete multiple notes at once |
|
||||||
|
|
||||||
### Notebooks (6)
|
### Notebooks (7)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
|||||||
| `update_label` | Update a label |
|
| `update_label` | Update a label |
|
||||||
| `delete_label` | Delete a label |
|
| `delete_label` | Delete a label |
|
||||||
|
|
||||||
### Reminders (1)
|
### Reminders (3)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `get_due_reminders` | Get due reminders |
|
| `get_due_reminders` | Get due reminders |
|
||||||
|
| `get_upcoming_reminders` | Get reminders due in the next N hours |
|
||||||
|
| `update_reminder` | Set, update, or clear a note reminder |
|
||||||
|
|
||||||
|
### Semantic & AI (3)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `find_similar_notes` | Find notes semantically similar to a given note |
|
||||||
|
| `get_memory_echo_insights` | List AI-discovered note connections |
|
||||||
|
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
|
||||||
|
|
||||||
|
### Filters & Statistics (2)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `get_notes_by_label` | List notes by label/tag |
|
||||||
|
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
|
||||||
|
|
||||||
### Utilities (2)
|
### Utilities (2)
|
||||||
|
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ export function validateConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port validation
|
// Port validation (check raw value before clamping)
|
||||||
const portValidation = validatePort(config.port);
|
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
|
||||||
|
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
|
||||||
if (!portValidation.valid) {
|
if (!portValidation.valid) {
|
||||||
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
|
|||||||
sessions: '/sessions',
|
sessions: '/sessions',
|
||||||
},
|
},
|
||||||
auth: { enabled: config.requireAuth },
|
auth: { enabled: config.requireAuth },
|
||||||
tools: 22,
|
tools: 29,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -445,25 +445,23 @@ app.all(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate tool input if present
|
// Validate tool input if present
|
||||||
if (req.body?.method) {
|
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
|
||||||
const toolName = req.body.method;
|
const toolName = req.body.params.name;
|
||||||
if (req.body?.params) {
|
const validation = validateAndSanitize(toolName, req.body.params.arguments);
|
||||||
const validation = validateAndSanitize(toolName, req.body.params);
|
if (!validation.success) {
|
||||||
if (!validation.success) {
|
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
return res
|
||||||
return res
|
.status(400)
|
||||||
.status(400)
|
.json(
|
||||||
.json(
|
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
detail: 'Input validation failed',
|
||||||
detail: 'Input validation failed',
|
field: validation.errors[0]?.field,
|
||||||
field: validation.errors[0]?.field,
|
context: { toolName, errors: validation.errors },
|
||||||
context: { toolName, errors: validation.errors },
|
})
|
||||||
})
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
// Update request with sanitized data
|
|
||||||
req.body.params = validation.data;
|
|
||||||
}
|
}
|
||||||
|
// Update request with sanitized data
|
||||||
|
req.body.params.arguments = validation.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = { userId: req.userSession?.userId || null };
|
const ctx = { userId: req.userSession?.userId || null };
|
||||||
@@ -539,7 +537,7 @@ async function main() {
|
|||||||
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
||||||
Timeout: ${config.requestTimeout}ms
|
Timeout: ${config.requestTimeout}ms
|
||||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
Tools: 22
|
Tools: 29
|
||||||
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ async function main() {
|
|||||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
User: ${config.userId || 'all'}
|
User: ${config.userId || 'all'}
|
||||||
Log Level: ${config.logLevel}
|
Log Level: ${config.logLevel}
|
||||||
Tools: 22
|
Tools: 29
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "memento-mcp-server",
|
"name": "memento-mcp-server",
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
|
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Run with: npm test
|
* Run with: npm test
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
|
|||||||
|
|
||||||
it('should allow safe HTML', () => {
|
it('should allow safe HTML', () => {
|
||||||
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
||||||
// This will be true because we check for any HTML tags
|
// Safe formatting tags are not flagged as XSS
|
||||||
// In production, you might want more sophisticated checking
|
expect(xss).toBe(false);
|
||||||
expect(xss).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sanitize input', () => {
|
it('should sanitize input', () => {
|
||||||
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
|
|||||||
'update_label',
|
'update_label',
|
||||||
'delete_label',
|
'delete_label',
|
||||||
'get_due_reminders',
|
'get_due_reminders',
|
||||||
|
'get_upcoming_reminders',
|
||||||
|
'update_reminder',
|
||||||
|
'find_similar_notes',
|
||||||
|
'get_memory_echo_insights',
|
||||||
|
'dismiss_memory_echo_insight',
|
||||||
|
'get_notes_by_label',
|
||||||
|
'get_note_statistics',
|
||||||
'export_notes',
|
'export_notes',
|
||||||
'import_notes',
|
'import_notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
it('should have all expected tools with schemas', () => {
|
it('should have all expected tools with schemas', async () => {
|
||||||
const { toolSchemas } = await import('../validation.js');
|
const { toolSchemas } = await import('../validation.js');
|
||||||
|
|
||||||
for (const toolName of toolNames) {
|
for (const toolName of toolNames) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
|
|||||||
const MAX_NOTES_LIMIT = 500;
|
const MAX_NOTES_LIMIT = 500;
|
||||||
|
|
||||||
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||||
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||||
|
|
||||||
export function parseNote(dbNote) {
|
export function parseNote(dbNote) {
|
||||||
if (!dbNote) return null;
|
if (!dbNote) return null;
|
||||||
@@ -463,6 +463,100 @@ const toolDefinitions = [
|
|||||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'get_upcoming_reminders',
|
||||||
|
description: 'Get notes with reminders due in the next N hours (default 24).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
|
||||||
|
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
|
||||||
|
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update_reminder',
|
||||||
|
description: 'Set, update, or clear a reminder on a note.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Note ID' },
|
||||||
|
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
|
||||||
|
isReminderDone: { type: 'boolean' },
|
||||||
|
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
|
||||||
|
reminderLocation: { type: 'string', nullable: true },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══ SEMANTIC / AI ═══
|
||||||
|
{
|
||||||
|
name: 'find_similar_notes',
|
||||||
|
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Source note ID' },
|
||||||
|
limit: { type: 'number', description: 'Max results', default: 10 },
|
||||||
|
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_memory_echo_insights',
|
||||||
|
description: 'List Memory Echo insights: AI-discovered connections between notes.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
|
||||||
|
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
|
||||||
|
limit: { type: 'number', description: 'Max results', default: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dismiss_memory_echo_insight',
|
||||||
|
description: 'Mark a Memory Echo insight as viewed or dismissed.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Insight ID' },
|
||||||
|
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
|
||||||
|
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══ FILTERS & STATISTICS ═══
|
||||||
|
{
|
||||||
|
name: 'get_notes_by_label',
|
||||||
|
description: 'List notes that have a specific label/tag.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Label text to match' },
|
||||||
|
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
|
||||||
|
includeArchived: { type: 'boolean', default: false },
|
||||||
|
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||||
|
},
|
||||||
|
required: ['label'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_note_statistics',
|
||||||
|
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
|
||||||
|
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||||
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
|
|||||||
title: args.title || null,
|
title: args.title || null,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
color: args.color || 'default',
|
color: args.color || 'default',
|
||||||
type: args.type || 'text',
|
type: args.type || 'richtext',
|
||||||
checkItems: args.checkItems ?? null,
|
checkItems: args.checkItems ?? null,
|
||||||
labels: args.labels ?? null,
|
labels: args.labels ?? null,
|
||||||
isPinned: args.isPinned || false,
|
isPinned: args.isPinned || false,
|
||||||
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
|
|||||||
return textResult({ count: reminders.length, reminders });
|
return textResult({ count: reminders.length, reminders });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'get_upcoming_reminders': {
|
||||||
|
const now = new Date();
|
||||||
|
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
|
||||||
|
const extra = {
|
||||||
|
reminder: { not: null, gte: now, lte: horizon },
|
||||||
|
isArchived: false,
|
||||||
|
};
|
||||||
|
if (!args.includeDone) extra.isReminderDone = false;
|
||||||
|
|
||||||
|
const reminders = await prisma.note.findMany({
|
||||||
|
where: noteWhere(uid, extra),
|
||||||
|
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
|
||||||
|
orderBy: { reminder: 'asc' },
|
||||||
|
take: args.limit || 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return textResult({ count: reminders.length, horizon, reminders });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_reminder': {
|
||||||
|
const d = { updatedAt: new Date() };
|
||||||
|
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||||
|
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
|
||||||
|
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
|
||||||
|
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
|
||||||
|
|
||||||
|
const note = await prisma.note.update({
|
||||||
|
where: { id: args.id, userId: uid, trashedAt: null },
|
||||||
|
data: d,
|
||||||
|
});
|
||||||
|
return textResult(parseNote(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ SEMANTIC / AI ═══
|
||||||
|
case 'find_similar_notes': {
|
||||||
|
const source = await prisma.note.findUnique({
|
||||||
|
where: { id: args.id, userId: uid, trashedAt: null },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
|
|
||||||
|
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
|
||||||
|
where: { noteId: args.id },
|
||||||
|
select: { embedding: true },
|
||||||
|
});
|
||||||
|
if (!sourceEmbedding) {
|
||||||
|
return textResult({
|
||||||
|
noteId: args.id,
|
||||||
|
error: 'Source note has no embedding. Index it first via the Memento app.',
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
|
||||||
|
? sourceEmbedding.embedding
|
||||||
|
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
|
||||||
|
const vecStr = `[${embeddingArray.join(',')}]`;
|
||||||
|
const limit = Math.min(args.limit || 10, 50);
|
||||||
|
const threshold = args.threshold ?? 0.7;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
|
||||||
|
n."notebookId", n."createdAt", n."updatedAt",
|
||||||
|
1 - (e."embedding"::vector <=> $1::vector) AS similarity
|
||||||
|
FROM "Note" n
|
||||||
|
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
|
||||||
|
WHERE n."trashedAt" IS NULL
|
||||||
|
AND n."isArchived" = false
|
||||||
|
AND n."userId" = $2
|
||||||
|
AND n.id != $3
|
||||||
|
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
|
||||||
|
ORDER BY e."embedding"::vector <=> $1::vector ASC
|
||||||
|
LIMIT $5`,
|
||||||
|
vecStr,
|
||||||
|
uid,
|
||||||
|
args.id,
|
||||||
|
threshold,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return textResult({
|
||||||
|
noteId: args.id,
|
||||||
|
sourceTitle: source.title || 'Untitled',
|
||||||
|
count: rows.length,
|
||||||
|
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_memory_echo_insights': {
|
||||||
|
const where = { userId: uid };
|
||||||
|
if (!args.includeViewed) where.viewed = false;
|
||||||
|
if (!args.includeDismissed) where.dismissed = false;
|
||||||
|
|
||||||
|
const insights = await prisma.memoryEchoInsight.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { similarityScore: 'desc' },
|
||||||
|
take: Math.min(args.limit || 20, 100),
|
||||||
|
include: {
|
||||||
|
note1: { select: { id: true, title: true } },
|
||||||
|
note2: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return textResult({
|
||||||
|
count: insights.length,
|
||||||
|
insights: insights.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
insight: i.insight,
|
||||||
|
similarityScore: i.similarityScore,
|
||||||
|
insightDate: i.insightDate,
|
||||||
|
viewed: i.viewed,
|
||||||
|
dismissed: i.dismissed,
|
||||||
|
note1: i.note1,
|
||||||
|
note2: i.note2,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dismiss_memory_echo_insight': {
|
||||||
|
const d = {};
|
||||||
|
if ('viewed' in args) d.viewed = args.viewed;
|
||||||
|
if ('dismissed' in args) d.dismissed = args.dismissed;
|
||||||
|
|
||||||
|
const insight = await prisma.memoryEchoInsight.update({
|
||||||
|
where: { id: args.id, userId: uid },
|
||||||
|
data: d,
|
||||||
|
});
|
||||||
|
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ FILTERS & STATISTICS ═══
|
||||||
|
case 'get_notes_by_label': {
|
||||||
|
const label = args.label.trim();
|
||||||
|
const extra = {};
|
||||||
|
if (!args.includeArchived) extra.isArchived = false;
|
||||||
|
if (args.notebookId) {
|
||||||
|
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels are stored as a JSON string like ["tag1","tag2"].
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
...noteWhere(uid, extra),
|
||||||
|
labels: { contains: label },
|
||||||
|
},
|
||||||
|
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||||
|
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
|
||||||
|
});
|
||||||
|
|
||||||
|
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_note_statistics': {
|
||||||
|
const baseWhere = { userId: uid };
|
||||||
|
const activeWhere = { ...baseWhere, trashedAt: null };
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalNotes,
|
||||||
|
archivedNotes,
|
||||||
|
trashedNotes,
|
||||||
|
notebookCount,
|
||||||
|
labelCount,
|
||||||
|
notesWithReminders,
|
||||||
|
notesWithImages,
|
||||||
|
notesWithCheckItems,
|
||||||
|
notesByType,
|
||||||
|
notesByColor,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.note.count({ where: activeWhere }),
|
||||||
|
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
|
||||||
|
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
|
||||||
|
prisma.notebook.count({ where: { userId: uid } }),
|
||||||
|
prisma.label.count({ where: { notebook: { userId: uid } } }),
|
||||||
|
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
|
||||||
|
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
|
||||||
|
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
|
||||||
|
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
|
||||||
|
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return textResult({
|
||||||
|
totalNotes,
|
||||||
|
archivedNotes,
|
||||||
|
trashedNotes,
|
||||||
|
activeNotes: totalNotes - archivedNotes,
|
||||||
|
notebookCount,
|
||||||
|
labelCount,
|
||||||
|
notesWithReminders,
|
||||||
|
notesWithImages,
|
||||||
|
notesWithCheckItems,
|
||||||
|
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
|
||||||
|
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ TRASH ═══
|
// ═══ TRASH ═══
|
||||||
case 'trash_note': {
|
case 'trash_note': {
|
||||||
const note = await prisma.note.update({
|
const note = await prisma.note.update({
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
|
|||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
fullDetails: boolSchema(false),
|
fullDetails: boolSchema(false),
|
||||||
limit: z.number().int().min(1).max(500).default(100),
|
limit: z.number().int().min(1).max(500).default(100),
|
||||||
offset: z.number().int().min(0).default(0),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const searchNotesSchema = z.object({
|
export const searchNotesSchema = z.object({
|
||||||
query: z.string().min(1).max(500),
|
query: z.string().min(1).max(500),
|
||||||
limit: z.number().int().min(1).max(100).default(50),
|
|
||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
includeArchived: boolSchema(false),
|
includeArchived: boolSchema(false),
|
||||||
});
|
});
|
||||||
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const togglePinSchema = z.object({
|
export const togglePinSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
pinned: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const toggleArchiveSchema = z.object({
|
export const toggleArchiveSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
archived: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* batch_move_notes input schema
|
* batch_move_notes input schema
|
||||||
*/
|
*/
|
||||||
export const batchMoveNotesSchema = z.object({
|
export const batchMoveNotesSchema = z.object({
|
||||||
noteIds: z.array(idSchema).min(1).max(100),
|
ids: z.array(idSchema).min(1).max(100),
|
||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
|
|||||||
* batch_delete_notes input schema
|
* batch_delete_notes input schema
|
||||||
*/
|
*/
|
||||||
export const batchDeleteNotesSchema = z.object({
|
export const batchDeleteNotesSchema = z.object({
|
||||||
noteIds: z.array(idSchema).min(1).max(100),
|
ids: z.array(idSchema).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const createNotebookSchema = z.object({
|
export const createNotebookSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
color: colorSchema.default('default'),
|
color: z.string().max(50).default('#3B82F6'),
|
||||||
icon: z.string().max(50).optional().nullable(),
|
icon: z.string().max(50).optional().nullable(),
|
||||||
parentId: idSchema.optional().nullable(),
|
parentId: idSchema.optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_notebooks input schema
|
* get_notebooks input schema
|
||||||
*/
|
*/
|
||||||
export const getNotebooksSchema = z.object({
|
export const getNotebooksSchema = z.object({});
|
||||||
includeHierarchy: boolSchema(false),
|
|
||||||
includeTrashed: boolSchema(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_notebook input schema
|
* get_notebook input schema
|
||||||
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
|
|||||||
export const updateNotebookSchema = z.object({
|
export const updateNotebookSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
color: colorSchema.optional(),
|
color: z.string().max(50).optional().nullable(),
|
||||||
icon: z.string().max(50).optional().nullable(),
|
icon: z.string().max(50).optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
parentId: idSchema.optional().nullable(),
|
parentId: idSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const createLabelSchema = z.object({
|
export const createLabelSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
color: colorSchema.default('default'),
|
color: z.string().max(50).optional().nullable(),
|
||||||
|
notebookId: idSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_labels input schema
|
* get_labels input schema
|
||||||
*/
|
*/
|
||||||
export const getLabelsSchema = z.object({
|
export const getLabelsSchema = z.object({
|
||||||
limit: z.number().int().min(1).max(500).default(100),
|
notebookId: idSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* get_due_reminders input schema
|
* get_due_reminders input schema
|
||||||
*/
|
*/
|
||||||
export const getDueRemindersSchema = z.object({
|
export const getDueRemindersSchema = z.object({});
|
||||||
before: isoDateSchema.optional().nullable(),
|
|
||||||
after: isoDateSchema.optional().nullable(),
|
|
||||||
includeDone: boolSchema(false),
|
|
||||||
limit: z.number().int().min(1).max(500).default(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* export_notes input schema
|
* export_notes input schema
|
||||||
*/
|
*/
|
||||||
export const exportNotesSchema = z.object({
|
export const exportNotesSchema = z.object({});
|
||||||
notebookId: idSchema.optional().nullable(),
|
|
||||||
includeArchived: boolSchema(false),
|
|
||||||
format: z.enum(['json', 'markdown']).default('json'),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* import_notes input schema
|
* import_notes input schema
|
||||||
*/
|
*/
|
||||||
export const importNotesSchema = z.object({
|
export const importNotesSchema = z.object({
|
||||||
notes: z.array(
|
data: z.object({
|
||||||
z.object({
|
version: z.string().optional(),
|
||||||
title: z.string().optional(),
|
data: z.object({
|
||||||
content: z.string(),
|
notes: z.array(z.any()).optional(),
|
||||||
color: colorSchema.optional(),
|
labels: z.array(z.any()).optional(),
|
||||||
labels: labelsSchema,
|
notebooks: z.array(z.any()).optional(),
|
||||||
notebookId: idSchema.optional().nullable(),
|
}).optional(),
|
||||||
})
|
}),
|
||||||
).min(1).max(100),
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find_similar_notes input schema
|
||||||
|
*/
|
||||||
|
export const findSimilarNotesSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
limit: z.number().int().min(1).max(50).default(10),
|
||||||
|
threshold: z.number().min(0).max(1).default(0.7),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_memory_echo_insights input schema
|
||||||
|
*/
|
||||||
|
export const getMemoryEchoInsightsSchema = z.object({
|
||||||
|
includeViewed: boolSchema(false),
|
||||||
|
includeDismissed: boolSchema(false),
|
||||||
|
limit: z.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dismiss_memory_echo_insight input schema
|
||||||
|
*/
|
||||||
|
export const dismissMemoryEchoInsightSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
viewed: boolSchema(true),
|
||||||
|
dismissed: boolSchema(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_notes_by_label input schema
|
||||||
|
*/
|
||||||
|
export const getNotesByLabelSchema = z.object({
|
||||||
|
label: z.string().min(1).max(100),
|
||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
overwrite: z.boolean().optional().default(false),
|
includeArchived: boolSchema(false),
|
||||||
|
limit: z.number().int().min(1).max(500).default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_note_statistics input schema
|
||||||
|
*/
|
||||||
|
export const getNoteStatisticsSchema = z.object({
|
||||||
|
includeArchived: boolSchema(false),
|
||||||
|
includeTrashed: boolSchema(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_upcoming_reminders input schema
|
||||||
|
*/
|
||||||
|
export const getUpcomingRemindersSchema = z.object({
|
||||||
|
hours: z.number().int().min(1).max(168).default(24),
|
||||||
|
includeDone: boolSchema(false),
|
||||||
|
limit: z.number().int().min(1).max(500).default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update_reminder input schema
|
||||||
|
*/
|
||||||
|
export const updateReminderSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
reminder: isoDateSchema,
|
||||||
|
isReminderDone: z.boolean().optional(),
|
||||||
|
reminderRecurrence: recurrenceSchema,
|
||||||
|
reminderLocation: z.string().max(500).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -427,6 +476,13 @@ export const toolSchemas = {
|
|||||||
get_due_reminders: getDueRemindersSchema,
|
get_due_reminders: getDueRemindersSchema,
|
||||||
export_notes: exportNotesSchema,
|
export_notes: exportNotesSchema,
|
||||||
import_notes: importNotesSchema,
|
import_notes: importNotesSchema,
|
||||||
|
find_similar_notes: findSimilarNotesSchema,
|
||||||
|
get_memory_echo_insights: getMemoryEchoInsightsSchema,
|
||||||
|
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
|
||||||
|
get_notes_by_label: getNotesByLabelSchema,
|
||||||
|
get_note_statistics: getNoteStatisticsSchema,
|
||||||
|
get_upcoming_reminders: getUpcomingRemindersSchema,
|
||||||
|
update_reminder: updateReminderSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errors: error.errors.map((e) => ({
|
errors: error.issues.map((e) => ({
|
||||||
field: e.path.join('.'),
|
field: e.path.join('.'),
|
||||||
message: e.message,
|
message: e.message,
|
||||||
code: e.code,
|
code: e.code,
|
||||||
|
|||||||
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 { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Gauge,
|
||||||
|
Coins,
|
||||||
|
Package,
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type PriceHealth = {
|
||||||
|
key: string
|
||||||
|
priceId: string
|
||||||
|
configured: boolean
|
||||||
|
source: string
|
||||||
|
stripeAmount: number | null
|
||||||
|
stripeCurrency: string | null
|
||||||
|
stripeActive: boolean | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type BillingAdminData = {
|
type BillingAdminData = {
|
||||||
billingConfig: Record<string, string>
|
billingConfig: Record<string, string>
|
||||||
@@ -19,7 +44,6 @@ type BillingAdminData = {
|
|||||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||||
}
|
}
|
||||||
tiers: string[]
|
tiers: string[]
|
||||||
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
|
||||||
creditAllocations?: Array<{
|
creditAllocations?: Array<{
|
||||||
tier: string
|
tier: string
|
||||||
monthlyCredits: number | null
|
monthlyCredits: number | null
|
||||||
@@ -31,6 +55,35 @@ type BillingAdminData = {
|
|||||||
credits: number
|
credits: number
|
||||||
defaultDisplay: string
|
defaultDisplay: string
|
||||||
}>
|
}>
|
||||||
|
stripeHealth?: {
|
||||||
|
secretConfigured: boolean
|
||||||
|
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
|
||||||
|
publishableConfigured: boolean
|
||||||
|
webhookSecretConfigured: boolean
|
||||||
|
billingEnabled: boolean
|
||||||
|
trialDays: number
|
||||||
|
prices: PriceHealth[]
|
||||||
|
}
|
||||||
|
subscriptionStats?: {
|
||||||
|
byTier: Record<string, number>
|
||||||
|
byStatus: Record<string, number>
|
||||||
|
cancelAtPeriodEnd: number
|
||||||
|
usersWithoutSub: number
|
||||||
|
trialing: number
|
||||||
|
pastDue: number
|
||||||
|
paidActive: number
|
||||||
|
recent: Array<{
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
tier: string
|
||||||
|
status: string
|
||||||
|
trialEndsAt: string | null
|
||||||
|
currentPeriodEnd: string | null
|
||||||
|
cancelAtPeriodEnd: boolean
|
||||||
|
hasStripeSub: boolean
|
||||||
|
updatedAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUBSCRIPTION_PRICE_KEYS = [
|
const SUBSCRIPTION_PRICE_KEYS = [
|
||||||
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
|
|||||||
'STRIPE_PRICE_CREDITS_L',
|
'STRIPE_PRICE_CREDITS_L',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
|
||||||
function formatStableUtc(iso: string): string {
|
function formatStableUtc(iso: string): string {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
|
|||||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
|
||||||
|
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
||||||
|
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
|
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
|
||||||
|
}
|
||||||
|
|
||||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||||
|
const [showTestGuide, setShowTestGuide] = useState(true)
|
||||||
|
|
||||||
|
const health = initialData.stripeHealth
|
||||||
|
const stats = initialData.subscriptionStats
|
||||||
|
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
|
||||||
|
|
||||||
const handleSaveBilling = async (formData: FormData) => {
|
const handleSaveBilling = async (formData: FormData) => {
|
||||||
setIsSavingBilling(true)
|
setIsSavingBilling(true)
|
||||||
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modeLabel =
|
||||||
|
health?.secretMode === 'test'
|
||||||
|
? t('admin.billing.modeTest')
|
||||||
|
: health?.secretMode === 'live'
|
||||||
|
? t('admin.billing.modeLive')
|
||||||
|
: health?.secretMode === 'placeholder'
|
||||||
|
? t('admin.billing.modePlaceholder')
|
||||||
|
: t('admin.billing.modeMissing')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe health */}
|
||||||
|
{health && (
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.publishableConfigured} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.webhookSecretConfigured} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(health.prices ?? []).map((row) => (
|
||||||
|
<tr key={row.key} className="border-t border-border/50">
|
||||||
|
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
|
||||||
|
<td className="p-2.5 font-mono truncate max-w-[180px]">
|
||||||
|
{row.configured ? row.priceId : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2.5">{row.source}</td>
|
||||||
|
<td className="p-2.5">
|
||||||
|
{!row.configured ? (
|
||||||
|
<span className="text-rose-600">{t('admin.billing.missing')}</span>
|
||||||
|
) : row.error ? (
|
||||||
|
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
|
||||||
|
) : row.stripeAmount != null ? (
|
||||||
|
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
|
||||||
|
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
|
||||||
|
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscription stats */}
|
||||||
|
{stats && (
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{Object.entries(stats.byTier).map(([tier, count]) => (
|
||||||
|
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
|
||||||
|
<span>{tier}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex justify-between text-xs text-muted-foreground pt-1">
|
||||||
|
<span>{t('admin.billing.usersWithoutSub')}</span>
|
||||||
|
<span className="tabular-nums">{stats.usersWithoutSub}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{Object.keys(stats.byStatus).length === 0 ? (
|
||||||
|
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
|
||||||
|
) : (
|
||||||
|
Object.entries(stats.byStatus).map(([status, count]) => (
|
||||||
|
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
|
||||||
|
<span>{status}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
|
||||||
|
{stats.recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.recent.map((row) => (
|
||||||
|
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
|
||||||
|
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
|
||||||
|
<td className="p-2.5">{row.tier}</td>
|
||||||
|
<td className="p-2.5">
|
||||||
|
{row.status}
|
||||||
|
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
|
||||||
|
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-2.5 text-muted-foreground">
|
||||||
|
{row.trialEndsAt
|
||||||
|
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
|
||||||
|
: row.currentPeriodEnd
|
||||||
|
? formatStableUtc(row.currentPeriodEnd)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* How to test Stripe */}
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTestGuide((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
|
||||||
|
</div>
|
||||||
|
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showTestGuide && (
|
||||||
|
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<ol className="list-decimal ps-5 space-y-2">
|
||||||
|
<li>{t('admin.billing.testStep1')}</li>
|
||||||
|
<li>{t('admin.billing.testStep2')}</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
|
||||||
|
{' — '}{t('admin.billing.testStep3')}
|
||||||
|
</li>
|
||||||
|
<li>{t('admin.billing.testStep4')}</li>
|
||||||
|
<li>{t('admin.billing.testStep5')}</li>
|
||||||
|
<li>{t('admin.billing.testStep6')}</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs border-t border-border/50 pt-3">
|
||||||
|
{t('admin.billing.testCardHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
|
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
|
||||||
<div key={key} className="space-y-2">
|
const meta = priceMap[key]
|
||||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
return (
|
||||||
<Input
|
<div key={key} className="space-y-2">
|
||||||
id={key}
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||||
name={key}
|
<Input
|
||||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
id={key}
|
||||||
placeholder="price_..."
|
name={key}
|
||||||
/>
|
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||||
</div>
|
placeholder="price_..."
|
||||||
))}
|
/>
|
||||||
|
{meta?.stripeAmount != null && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{meta?.error && (
|
||||||
|
<p className="text-[11px] text-rose-600">{meta.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Solde unique — source de vérité du débit */}
|
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||||
{initialData.usageOverview.lastSyncedAt
|
{initialData.usageOverview.lastSyncedAt
|
||||||
? ` · ${t('admin.billing.lastSync', {
|
? ` · ${t('admin.billing.lastSync', {
|
||||||
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
|
||||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||||
})}`
|
})}`
|
||||||
: ` · ${t('admin.billing.notSynced')}`}
|
: ` · ${t('admin.billing.notSynced')}`}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { confirmEmail } from '@/app/actions/auth-verify'
|
||||||
|
|
||||||
|
function VerifyEmailContent() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('invalid')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
const result = await confirmEmail(token)
|
||||||
|
if (cancelled) return
|
||||||
|
if (result.success) setStatus('ok')
|
||||||
|
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
|
||||||
|
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'ok') {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||||
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle2 size={28} className="text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/login?verified=1" className="block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('auth.signIn')}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||||
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||||
|
<AlertCircle size={28} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-serif font-bold">
|
||||||
|
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{status === 'expired'
|
||||||
|
? t('auth.verifyExpiredDescription')
|
||||||
|
: t('auth.verifyInvalidDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/check-email" className="block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('auth.resendVerification')}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||||
|
<VerifyEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Metadata } from 'next'
|
|
||||||
import { auth } from '@/auth'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { ChatContainer } from '@/components/chat/chat-container'
|
|
||||||
import { getConversations } from '@/app/actions/chat-actions'
|
|
||||||
import { getSystemConfig } from '@/lib/config'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Chat IA | Memento',
|
|
||||||
description: 'Discutez avec vos notes et vos agents IA',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ChatPage() {
|
|
||||||
const session = await auth()
|
|
||||||
if (!session?.user?.id) redirect('/login')
|
|
||||||
|
|
||||||
const userId = session.user.id
|
|
||||||
|
|
||||||
// Fetch initial data
|
|
||||||
const [conversations, notebooks, config] = await Promise.all([
|
|
||||||
getConversations(),
|
|
||||||
prisma.notebook.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { order: 'asc' }
|
|
||||||
}),
|
|
||||||
getSystemConfig(),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Check if web search tools are configured
|
|
||||||
const webSearchAvailable = !!(
|
|
||||||
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
|
||||||
<ChatContainer
|
|
||||||
initialConversations={conversations}
|
|
||||||
notebooks={notebooks}
|
|
||||||
webSearchAvailable={webSearchAvailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { PageView } from '@/components/interactive-page/page-view'
|
||||||
|
import { validateInteractivePage } from '@/lib/interactive-page'
|
||||||
|
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev preview of PageView + thermo fixture (spec §8.3).
|
||||||
|
* Only available outside production.
|
||||||
|
*/
|
||||||
|
export default function InteractivePagePreviewPage() {
|
||||||
|
if (process.env.NODE_ENV === 'production') notFound()
|
||||||
|
|
||||||
|
const parsed = validateInteractivePage(thermoFixture)
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-sm text-destructive">
|
||||||
|
Fixture invalide : {parsed.issues[0]?.message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageView page={parsed.page} demoMode="interactive" />
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
|
||||||
// D3 uses browser APIs — must be loaded client-side only
|
|
||||||
const NoteGraphView = dynamic(
|
|
||||||
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
export default function GraphPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-screen overflow-hidden">
|
|
||||||
<NoteGraphView />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
Database,
|
||||||
ArrowRight,
|
|
||||||
Menu,
|
Menu,
|
||||||
Network,
|
Network,
|
||||||
List,
|
List,
|
||||||
@@ -28,7 +27,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
||||||
import { createNote } from '@/app/actions/notes'
|
import { createNote } from '@/app/actions/notes'
|
||||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||||
@@ -86,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
|
|||||||
|
|
||||||
export default function InsightsPage() {
|
export default function InsightsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const { t, language: locale } = useLanguage()
|
const { t, language: locale } = useLanguage()
|
||||||
|
|
||||||
const formatSyncTime = useCallback(
|
const formatSyncTime = useCallback(
|
||||||
@@ -131,6 +130,15 @@ export default function InsightsPage() {
|
|||||||
loadInitialData()
|
loadInitialData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clusterParam = searchParams.get('cluster')
|
||||||
|
if (!clusterParam || clusters.length === 0) return
|
||||||
|
const match = clusters.find(
|
||||||
|
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
|
||||||
|
)
|
||||||
|
if (match) setSelectedClusterId(match.id)
|
||||||
|
}, [searchParams, clusters])
|
||||||
|
|
||||||
// ─── Données calculées ───────────────────────────────────────────────────────
|
// ─── Données calculées ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const selectedCluster = useMemo(
|
const selectedCluster = useMemo(
|
||||||
@@ -455,9 +463,6 @@ export default function InsightsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
|
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
|
||||||
<span>{t('insightsView.semanticGraphLegend')}</span>
|
<span>{t('insightsView.semanticGraphLegend')}</span>
|
||||||
<Link href="/graph" className="inline-flex items-center gap-0.5 text-ochre hover:underline font-medium">
|
|
||||||
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
|
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
|
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
|
||||||
@@ -27,6 +27,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||||
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
|
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const aiConsentSectionRef = useRef<HTMLDivElement>(null)
|
||||||
|
const highlightAiConsent = searchParams?.get('highlight') === 'aiConsent'
|
||||||
|
const [showConsentHighlight, setShowConsentHighlight] = useState(highlightAiConsent)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightAiConsent && aiConsentSectionRef.current) {
|
||||||
|
setShowConsentHighlight(true)
|
||||||
|
aiConsentSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
const timer = setTimeout(() => setShowConsentHighlight(false), 4000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [highlightAiConsent])
|
||||||
|
|
||||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||||
@@ -76,6 +90,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
{t('generalSettings.description')}
|
{t('generalSettings.description')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{highlightAiConsent && (
|
||||||
|
<div className="rounded-xl border border-brand-accent/30 bg-brand-accent/10 p-4 text-sm font-medium text-ink">
|
||||||
|
{t('consent.ai.settingsBanner')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
@@ -202,7 +222,15 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
|
<div
|
||||||
|
ref={aiConsentSectionRef}
|
||||||
|
className={cn(
|
||||||
|
'bg-white/40 dark:bg-white/5 border rounded-xl p-8 space-y-6 md:col-span-2 transition-all duration-500',
|
||||||
|
showConsentHighlight
|
||||||
|
? 'border-brand-accent ring-2 ring-brand-accent/40 shadow-lg shadow-brand-accent/10'
|
||||||
|
: 'border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useLanguage } from '@/lib/i18n';
|
|
||||||
|
|
||||||
export default function SupportPage() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-10 max-w-4xl">
|
|
||||||
<div className="text-center mb-10">
|
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
|
|
||||||
{t('support.title')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg">
|
|
||||||
{t('support.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
|
||||||
<Card className="border-2 border-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="text-2xl">☕</span>
|
|
||||||
{t('support.buyMeACoffee')}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="mb-4">
|
|
||||||
{t('support.donationDescription')}
|
|
||||||
</p>
|
|
||||||
<Button asChild className="w-full">
|
|
||||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
|
||||||
{t('support.donateOnKofi')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{t('support.kofiDescription')}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-2 border-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="text-2xl">💚</span>
|
|
||||||
{t('support.sponsorOnGithub')}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="mb-4">
|
|
||||||
{t('support.sponsorDescription')}
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
|
||||||
{t('support.sponsorOnGithub')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{t('support.githubDescription')}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mb-10">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>☕ Keeps me fueled with coffee</li>
|
|
||||||
<li>🐛 Covers hosting and server costs</li>
|
|
||||||
<li>✨ Funds development of new features</li>
|
|
||||||
<li>📚 Improves documentation</li>
|
|
||||||
<li>🌍 Keeps Memento 100% open-source</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
|
||||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
|
||||||
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
|
|
||||||
<li>💎 $100/month - Platinum: Custom features, consulting</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm mb-4">
|
|
||||||
{t('support.transparencyDescription')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t('support.hostingServers')}</span>
|
|
||||||
<span className="font-mono">~$20/month</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t('support.domainSSL')}</span>
|
|
||||||
<span className="font-mono">~$15/year</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{t('support.aiApiCosts')}</span>
|
|
||||||
<span className="font-mono">~$30/month</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-t pt-2">
|
|
||||||
<span className="font-semibold">{t('support.totalExpenses')}</span>
|
|
||||||
<span className="font-mono font-semibold">~$50/month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-4">
|
|
||||||
Any amount beyond these costs goes directly into improving Memento
|
|
||||||
and funding new features. Thank you for your support! 💚
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="mt-10 text-center">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
|
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
|
||||||
⭐ {t('support.starGithub')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
|
||||||
🐛 {t('support.reportBug')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
|
||||||
📝 {t('support.contributeCode')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
|
||||||
🐦 {t('support.shareTwitter')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
|||||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||||
import { ReadingProgress } from '@/components/publish/reading-progress'
|
import { ReadingProgress } from '@/components/publish/reading-progress'
|
||||||
import { CopyLinkButton } from '@/components/publish/copy-link-button'
|
import { CopyLinkButton } from '@/components/publish/copy-link-button'
|
||||||
|
import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
|
||||||
|
import { isInteractivePageTemplate } from '@/lib/publish/types'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
|||||||
const note = await getPublishedNote(slug)
|
const note = await getPublishedNote(slug)
|
||||||
if (!note) notFound()
|
if (!note) notFound()
|
||||||
|
|
||||||
|
const isStale = Boolean(
|
||||||
|
note.publishedSourceHash
|
||||||
|
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isInteractivePageTemplate(note.publishedTemplate) &&
|
||||||
|
note.publishedContent
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<InteractivePublishedPage
|
||||||
|
publishedContent={note.publishedContent}
|
||||||
|
isStale={isStale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const usesAiLayout = Boolean(note.publishedContent)
|
const usesAiLayout = Boolean(note.publishedContent)
|
||||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||||
|
|
||||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||||
const readingTime = estimateReadingTime(readingSource)
|
const readingTime = estimateReadingTime(readingSource)
|
||||||
const isStale = Boolean(
|
|
||||||
note.publishedSourceHash
|
|
||||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
|
||||||
)
|
|
||||||
|
|
||||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,38 @@ import { motion } from 'motion/react'
|
|||||||
import { Shield } from 'lucide-react'
|
import { Shield } from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||||
|
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||||
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
{
|
||||||
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
key: 'pro',
|
||||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
popular: true,
|
||||||
|
hasTrial: true,
|
||||||
|
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
|
||||||
|
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'business',
|
||||||
|
popular: false,
|
||||||
|
hasTrial: true,
|
||||||
|
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
|
||||||
|
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enterprise',
|
||||||
|
popular: false,
|
||||||
|
hasTrial: false,
|
||||||
|
price: t('landing.pricing.enterprisePrice'),
|
||||||
|
period: '',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +62,9 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
<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>
|
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
|
||||||
|
{t('landing.pricing.savePercent')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,9 +84,22 @@ export default function PricingPage() {
|
|||||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<p className={`text-[11px] font-semibold mb-3 ${plan.popular ? 'text-ochre' : 'text-brand-accent'}`}>
|
||||||
|
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4 mb-10">
|
<div className="flex-1 space-y-4 mb-10">
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<div 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-medium">{t('landing.pricing.trialFeature', { days: trialDays })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||||
@@ -82,7 +117,9 @@ export default function PricingPage() {
|
|||||||
onClick={() => router.push('/register')}
|
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
{t(`landing.pricing.${plan.key}.cta`)}
|
{plan.hasTrial
|
||||||
|
? t('landing.pricing.trialCta', { days: trialDays })
|
||||||
|
: t(`landing.pricing.${plan.key}.cta`)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
411
memento-note/app/(public)/privacy/page.tsx
Normal file
411
memento-note/app/(public)/privacy/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { headers } from 'next/headers'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: Promise<{ lang?: string | string[] }>
|
||||||
|
}) {
|
||||||
|
const sp = searchParams ? (await searchParams) : undefined
|
||||||
|
const locale = await pickLocale(sp?.lang)
|
||||||
|
return {
|
||||||
|
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
|
||||||
|
description: locale === 'fr'
|
||||||
|
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
|
||||||
|
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
|
||||||
|
alternates: { canonical: 'https://memento-note.com/privacy' },
|
||||||
|
robots: { index: true, follow: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Locale = 'en' | 'fr'
|
||||||
|
const SUPPORTED: Locale[] = ['en', 'fr']
|
||||||
|
const RTL: Locale[] = []
|
||||||
|
|
||||||
|
async function pickLocale(searchLang?: string | string[]): Promise<Locale> {
|
||||||
|
// 1. Explicit ?lang= override
|
||||||
|
const raw = Array.isArray(searchLang) ? searchLang[0] : searchLang
|
||||||
|
if (raw && SUPPORTED.includes(raw.toLowerCase() as Locale)) return raw.toLowerCase() as Locale
|
||||||
|
// 2. Accept-Language header
|
||||||
|
try {
|
||||||
|
const h = await headers()
|
||||||
|
const accept = parseAcceptLanguage(h.get('accept-language'))
|
||||||
|
const first = accept?.[0]?.toLowerCase()
|
||||||
|
if (first && SUPPORTED.includes(first as Locale)) return first as Locale
|
||||||
|
if (first && first.startsWith('fr')) return 'fr'
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section = { id: string; title: string; body: string[] }
|
||||||
|
type Doc = {
|
||||||
|
title: string
|
||||||
|
intro: string
|
||||||
|
lastUpdated: string
|
||||||
|
sections: Section[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCS: Record<Locale, Doc> = {
|
||||||
|
fr: {
|
||||||
|
title: 'Politique de confidentialité',
|
||||||
|
intro:
|
||||||
|
'Cette politique décrit comment Memento (Memento Labs) collecte, utilise et protège vos données, y compris via l\'application web, l\'extension navigateur Web Clipper et les fonctionnalités d\'intelligence artificielle. Nous concevons Memento comme un Second Brain personnel : par défaut, vos données vous appartiennent et ne sont pas partagées.',
|
||||||
|
lastUpdated: 'Dernière mise à jour : juillet 2026',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'data-collected',
|
||||||
|
title: '1. Données collectées',
|
||||||
|
body: [
|
||||||
|
'Compte et authentification : adresse e-mail, nom facultatif, mot de passe haché (bcrypt), ou identifiant Google via OAuth (Google Auth Platform).',
|
||||||
|
'Carnets et notes : contenu, titre, étiquettes, images jointes, pièces jointes (PDF, images, documents), métadonnées (dates, ordre, couleur, épinglé, archivé), et historique des versions (snapshots).',
|
||||||
|
'Rappels et notifications : date/heure, récurrence, lieu facultatif, état (fait/non fait).',
|
||||||
|
'Données IA : titre et corps des notes envoyés à votre fournisseur d\'IA configuré pour générer résumés, tags, embeddings, Memory Echo, reformulations, traduction, suggestions. Avec BYOK (votre propre clé), l\'envoi va directement vers votre fournisseur ; sinon, il transite par notre routeur IA mutualisé.',
|
||||||
|
'Extension Web Clipper (Chrome / Firefox) : lorsque vous cliquez explicitement « Clipper », l\'extension lit l\'URL active, le titre de la page, la sélection de texte et (selon votre choix) le HTML complet de la page. Aucune lecture du contenu de page n\'a lieu tant que vous n\'initiez pas l\'action.',
|
||||||
|
'Cookies techniques et de session : strictement nécessaires à l\'authentification (cookies NextAuth) et aux préférences (langue, thème, panneau de configuration de l\'extension). Aucun cookie publicitaire, aucun tracker tiers par défaut.',
|
||||||
|
'Facturation et quotas : pour les tiers payants, référence de commande et jetons d\'utilisation IA stockés pour appliquer les quotas (réservation atomique). Aucune donnée bancaire (traitée par Stripe).',
|
||||||
|
'Métadonnées techniques : adresse IP (journalisée ~30 jours à des fins anti-abus), user-agent, version de l\'extension, codes d\'erreur anonymisés.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usage',
|
||||||
|
title: '2. Usage des données',
|
||||||
|
body: [
|
||||||
|
'Fournir le service : synchronisation de vos carnets, recherche, Memory Echo, Memory Echo insights, agents, publication web de notes, clips depuis l\'extension.',
|
||||||
|
'Amélioration : journaux d\'erreur anonymisés et métriques agrégées (Prometheus + dashboards Grafana internes) pour diagnostiquer les pannes et améliorer les performances.',
|
||||||
|
'Sécurité : détection d\'abus, protection contre le credential stuffing et les dépassements de quotas.',
|
||||||
|
'Nous ne vendons jamais vos données. Nous ne les partageons pas avec des annonceurs. Aucun profilage publicitaire.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'third-parties',
|
||||||
|
title: '3. Services tiers et sous-traitants',
|
||||||
|
body: [
|
||||||
|
'Google OAuth : authentification optionnelle (Branding → Audience « In production »).',
|
||||||
|
'Google Cloud Storage (GCP) : hébergement des fichiers joints et des pièces jointes, région Europe (eu-west-3 / europe-west9).',
|
||||||
|
'PostgreSQL (Docker self-hosted) : base principale, sauvegardes chiffrées au repos.',
|
||||||
|
'Redis (Docker self-hosted) : cache de session, rate limiting, files de tâches.',
|
||||||
|
'Fournisseurs d\'IA (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, ou votre propre clé BYOK) : envoi du titre et du contenu de la note sélectionnée pour les fonctionnalités IA. Aucun entraînement n\'est effectué sur vos données par notre intermédiaire ; voir la politique du fournisseur retenu.',
|
||||||
|
'Stripe : facturation des abonnements. Les données de paiement sont gérées exclusivement par Stripe (PCI-DSS).',
|
||||||
|
'Resend : e-mails transactionnels (vérification de compte, réinitialisation de mot de passe, alertes quota).',
|
||||||
|
'Google Fonts (CDN fonts.googleapis.com) : polices web sur la landing page et le side panel de l\'extension — adresse IP communiquée à Google.',
|
||||||
|
'Google Favicons (google.com/s2/favicons) : récupération d\'icônes de sites dans l\'extension — l\'URL du domaine est communiquée à Google.',
|
||||||
|
'Grafana Cloud (monitoring optionnel) : métriques agrégées anonymisées.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extension',
|
||||||
|
title: '4. Permissions de l\'extension navigateur',
|
||||||
|
body: [
|
||||||
|
'activeTab : lecture du contenu de l\'onglet actif uniquement lorsque vous cliquez le bouton « Clipper ». Aucune lecture passive.',
|
||||||
|
'scripting : injection d\'un petit script de capture de sélection après votre clic explicite.',
|
||||||
|
'storage : mémorisation de l\'URL de votre serveur Memento et du dernier carnet utilisé.',
|
||||||
|
'sidePanel (Chrome) / sidebar_action (Firefox) : ouverture du panneau latéral pour afficher l\'interface du clipper.',
|
||||||
|
'tabs : récupération de l\'URL et du titre de l\'onglet actif pour le contexte du clip.',
|
||||||
|
'host_permissions : limitées à memento-note.com en production. Les hôtes étendus (localhost / LAN) sont disponibles uniquement en build développeur.',
|
||||||
|
'L\'extension ne lit jamais l\'historique de navigation. Elle ne communique avec aucun serveur autre que l\'instance Memento que vous avez explicitement configurée.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
title: '5. Intelligence artificielle et BYOK',
|
||||||
|
body: [
|
||||||
|
'Les fonctionnalités IA (résumé, tags, Memory Echo, embeddings sémantiques, agents) consomment un quota mesuré en tokens. Le quota est réservé atomiquement avant chaque appel.',
|
||||||
|
'Vous pouvez fournir votre propre clé d\'API (BYOK) pour contourner les quotas mutualisés et faire transiter les appels directement par votre fournisseur. La clé est chiffrée au repos (AES-256-GCM) et n\'est jamais affichée en clair après saisie.',
|
||||||
|
'Aucun entraînement de modèle n\'est effectué sur vos données par notre intermédiaire.',
|
||||||
|
'Vous pouvez désactiver individuellement chaque fonctionnalité IA dans Réglages → IA.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retention',
|
||||||
|
title: '6. Conservation des données',
|
||||||
|
body: [
|
||||||
|
'Carnets et notes : conservés tant que votre compte est actif. Suppression définitive dans les 30 jours suivant la suppression du compte (sauf obligation légale contraire).',
|
||||||
|
'Corbeille : notes supprimées conservées 30 jours avant purge définitive.',
|
||||||
|
'Journaux d\'erreur : 90 jours maximum, agrégés ensuite.',
|
||||||
|
'Sauvegardes base de données : conservées 30 jours en rotation chiffrée.',
|
||||||
|
'Métadonnées de facturation : conservées 10 ans (obligation comptable).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rights',
|
||||||
|
title: '7. Vos droits (RGPD / CCPA)',
|
||||||
|
body: [
|
||||||
|
'Accès : export complet de vos notes, carnets, étiquettes et paramètres via Réglages → Données.',
|
||||||
|
'Rectification : édition directe dans l\'interface.',
|
||||||
|
'Effacement : suppression de compte dans Réglages → Données. Effacement définitif sous 30 jours.',
|
||||||
|
'Portabilité : export JSON des notes, carnets et étiquettes (équivalent de l\'outil `export_notes` du serveur MCP).',
|
||||||
|
'Opposition au traitement IA : désactivation des fonctionnalités IA dans Réglages → IA.',
|
||||||
|
'Réclamation : vous pouvez saisir la CNIL (France) ou l\'autorité de contrôle de votre lieu de résidence.',
|
||||||
|
'Pour exercer vos droits : privacy@memento-note.com.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
title: '8. Sécurité',
|
||||||
|
body: [
|
||||||
|
'Transport : HTTPS obligatoire, HSTS activé, en-têtes de sécurité (CSP, X-Frame-Options, X-Content-Type-Options).',
|
||||||
|
'Stockage : mots de passe hachés bcrypt, clés API chiffrées AES-256-GCM, secrets en variables d\'environnement chiffrées au repos.',
|
||||||
|
'Authentification : cookies de session NextAuth, JWT signés, expiration glissante.',
|
||||||
|
'Audit : journaux d\'audit log pour les actions sensibles (changement de mot de passe, export de données, suppression de compte).',
|
||||||
|
'Bug bounty : security@memento-note.com pour signaler une vulnérabilité. Programme responsable de divulgation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minors',
|
||||||
|
title: '9. Mineurs',
|
||||||
|
body: [
|
||||||
|
'Memento n\'est pas destiné aux enfants de moins de 16 ans (RGPD) ou 13 ans (COPPA). Nous ne collectons pas sciemment de données auprès de mineurs. Si vous pensez qu\'un mineur a fourni des données, contactez privacy@memento-note.com pour suppression.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'changes',
|
||||||
|
title: '10. Modifications de cette politique',
|
||||||
|
body: [
|
||||||
|
'Toute modification substantielle est annoncée par e-mail au moins 30 jours à l\'avance et par bannière dans l\'application. La date de « dernière mise à jour » en haut de cette page est mise à jour à chaque révision.',
|
||||||
|
'Les modifications non substantielles (corrections de formulation, liens mis à jour) sont publiées sans préavis. La version précédente reste accessible sur demande.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
title: '11. Contact et responsable de traitement',
|
||||||
|
body: [
|
||||||
|
'Memento Labs — Responsable de traitement : privacy@memento-note.com',
|
||||||
|
'Délégué à la protection des données (DPO) : dpo@memento-note.com',
|
||||||
|
'Adresse postale : Memento Labs, [adresse], France',
|
||||||
|
'Hébergeur des données : serveur self-hosted sur infrastructure Memento Labs (PostgreSQL + Redis Docker, région Europe).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Privacy Policy',
|
||||||
|
intro:
|
||||||
|
'This policy describes how Memento (Memento Labs) collects, uses and protects your data, including through the web app, the Web Clipper browser extension, and AI features. We design Memento as a personal Second Brain: by default your data belongs to you and is not shared.',
|
||||||
|
lastUpdated: 'Last updated: July 2026',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'data-collected',
|
||||||
|
title: '1. Data we collect',
|
||||||
|
body: [
|
||||||
|
'Account and authentication: email address, optional name, hashed password (bcrypt), or Google identifier via OAuth (Google Auth Platform).',
|
||||||
|
'Notebooks and notes: content, title, labels, attached images and files (PDF, images, documents), metadata (dates, order, color, pinned, archived), and version history (snapshots).',
|
||||||
|
'Reminders and notifications: date/time, recurrence, optional location, status (done/not done).',
|
||||||
|
'AI data: note title and body sent to your configured AI provider to generate summaries, tags, embeddings, Memory Echo, reformulations, translations, suggestions. With BYOK (your own key), requests go directly to your provider; otherwise they transit through our shared AI router.',
|
||||||
|
'Web Clipper extension (Chrome / Firefox): when you explicitly click "Clip", the extension reads the active URL, page title, text selection and (depending on your choice) the full page HTML. No page content is read unless you initiate the action.',
|
||||||
|
'Technical and session cookies: strictly necessary for authentication (NextAuth cookies) and preferences (language, theme, extension settings panel). No advertising cookies, no third-party trackers by default.',
|
||||||
|
'Billing and quotas: for paid tiers, order reference and AI usage tokens stored to enforce quotas (atomic reservation). No bank card data (handled by Stripe).',
|
||||||
|
'Technical metadata: IP address (logged ~30 days for anti-abuse), user agent, extension version, anonymized error codes.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usage',
|
||||||
|
title: '2. How we use data',
|
||||||
|
body: [
|
||||||
|
'Provide the service: notebook sync, search, Memory Echo, Memory Echo insights, agents, web publishing of notes, clips from the extension.',
|
||||||
|
'Improvement: anonymized error logs and aggregated metrics (internal Prometheus + Grafana dashboards) to diagnose failures and improve performance.',
|
||||||
|
'Security: abuse detection, protection against credential stuffing and quota overruns.',
|
||||||
|
'We never sell your data. We never share it with advertisers. No advertising profiling.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'third-parties',
|
||||||
|
title: '3. Third parties and sub-processors',
|
||||||
|
body: [
|
||||||
|
'Google OAuth: optional authentication (Branding → Audience "In production").',
|
||||||
|
'Google Cloud Storage (GCP): hosting of attached files and attachments, Europe region (eu-west-3 / europe-west9).',
|
||||||
|
'PostgreSQL (Docker self-hosted): main database, encrypted backups at rest.',
|
||||||
|
'Redis (Docker self-hosted): session cache, rate limiting, job queues.',
|
||||||
|
'AI providers (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, or your own BYOK key): the title and content of the selected note are sent for AI features. No training is performed on your data through us; see the chosen provider\'s policy.',
|
||||||
|
'Stripe: subscription billing. Payment data is handled exclusively by Stripe (PCI-DSS).',
|
||||||
|
'Resend: transactional emails (account verification, password reset, quota alerts).',
|
||||||
|
'Google Fonts (CDN fonts.googleapis.com): web fonts on the landing page and the extension side panel — IP address is shared with Google.',
|
||||||
|
'Google Favicons (google.com/s2/favicons): site icon retrieval in the extension — the domain URL is shared with Google.',
|
||||||
|
'Grafana Cloud (optional monitoring): anonymized aggregated metrics.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extension',
|
||||||
|
title: '4. Browser extension permissions',
|
||||||
|
body: [
|
||||||
|
'activeTab: read the active tab content only when you click the "Clip" button. No passive reading.',
|
||||||
|
'scripting: inject a small selection-capture script after your explicit click.',
|
||||||
|
'storage: remember your Memento server URL and the last-used notebook.',
|
||||||
|
'sidePanel (Chrome) / sidebar_action (Firefox): open the side panel to display the clipper UI.',
|
||||||
|
'tabs: retrieve the active tab URL and title for the clip context.',
|
||||||
|
'host_permissions: limited to memento-note.com in production. Extended hosts (localhost / LAN) are only available in the developer build.',
|
||||||
|
'The extension never reads your browsing history. It never communicates with any server other than the Memento instance you explicitly configured.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
title: '5. Artificial intelligence and BYOK',
|
||||||
|
body: [
|
||||||
|
'AI features (summary, tags, Memory Echo, semantic embeddings, agents) consume a token-based quota. The quota is atomically reserved before each call.',
|
||||||
|
'You can provide your own API key (BYOK) to bypass shared quotas and route calls directly through your provider. The key is encrypted at rest (AES-256-GCM) and is never displayed in clear text after entry.',
|
||||||
|
'No model training is performed on your data through us.',
|
||||||
|
'You can disable each AI feature individually in Settings → AI.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retention',
|
||||||
|
title: '6. Data retention',
|
||||||
|
body: [
|
||||||
|
'Notebooks and notes: kept as long as your account is active. Permanently deleted within 30 days of account deletion (unless a legal obligation requires otherwise).',
|
||||||
|
'Trash: deleted notes kept for 30 days before permanent purge.',
|
||||||
|
'Error logs: maximum 90 days, then aggregated.',
|
||||||
|
'Database backups: kept 30 days in encrypted rotation.',
|
||||||
|
'Billing metadata: kept 10 years (accounting obligation).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rights',
|
||||||
|
title: '7. Your rights (GDPR / CCPA)',
|
||||||
|
body: [
|
||||||
|
'Access: full export of your notes, notebooks, labels and settings via Settings → Data.',
|
||||||
|
'Rectification: edit directly in the interface.',
|
||||||
|
'Erasure: account deletion in Settings → Data. Definitive erasure within 30 days.',
|
||||||
|
'Portability: JSON export of notes, notebooks and labels (equivalent of the `export_notes` MCP server tool).',
|
||||||
|
'Opt-out of AI processing: disable AI features in Settings → AI.',
|
||||||
|
'Complaint: you may contact the CNIL (France) or the supervisory authority of your place of residence.',
|
||||||
|
'To exercise your rights: privacy@memento-note.com.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
title: '8. Security',
|
||||||
|
body: [
|
||||||
|
'Transport: HTTPS enforced, HSTS enabled, security headers (CSP, X-Frame-Options, X-Content-Type-Options).',
|
||||||
|
'Storage: bcrypt-hashed passwords, AES-256-GCM-encrypted API keys, secrets in encrypted environment variables at rest.',
|
||||||
|
'Authentication: NextAuth session cookies, signed JWT, sliding expiration.',
|
||||||
|
'Audit: audit logs for sensitive actions (password change, data export, account deletion).',
|
||||||
|
'Bug bounty: security@memento-note.com to report a vulnerability. Responsible disclosure program.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minors',
|
||||||
|
title: '9. Minors',
|
||||||
|
body: [
|
||||||
|
'Memento is not intended for children under 16 (GDPR) or 13 (COPPA). We do not knowingly collect data from minors. If you believe a minor has provided data, contact privacy@memento-note.com for deletion.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'changes',
|
||||||
|
title: '10. Changes to this policy',
|
||||||
|
body: [
|
||||||
|
'Any substantial change is announced by email at least 30 days in advance and via an in-app banner. The "Last updated" date at the top of this page is updated with each revision.',
|
||||||
|
'Non-substantial changes (wording fixes, updated links) are published without notice. The previous version remains accessible on request.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
title: '11. Contact and data controller',
|
||||||
|
body: [
|
||||||
|
'Memento Labs — Data controller: privacy@memento-note.com',
|
||||||
|
'Data Protection Officer (DPO): dpo@memento-note.com',
|
||||||
|
'Postal address: Memento Labs, [address], France',
|
||||||
|
'Data hosting: self-hosted on Memento Labs infrastructure (PostgreSQL + Redis Docker, Europe region).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: Promise<{ lang?: string | string[] }>
|
||||||
|
}) {
|
||||||
|
const sp = searchParams ? (await searchParams) : undefined
|
||||||
|
const locale = await pickLocale(sp?.lang)
|
||||||
|
const doc = DOCS[locale]
|
||||||
|
const isRtl = RTL.includes(locale)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
lang={locale}
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white"
|
||||||
|
>
|
||||||
|
<header className="px-5 sm:px-8 py-6 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
|
||||||
|
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="?lang=fr"
|
||||||
|
aria-current={locale === 'fr' ? 'true' : undefined}
|
||||||
|
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
|
||||||
|
locale === 'fr'
|
||||||
|
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
|
||||||
|
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
FR
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="?lang=en"
|
||||||
|
aria-current={locale === 'en' ? 'true' : undefined}
|
||||||
|
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
|
||||||
|
locale === 'en'
|
||||||
|
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
|
||||||
|
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
|
||||||
|
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
|
||||||
|
</p>
|
||||||
|
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
|
||||||
|
<p className="text-sm text-white/40 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">
|
||||||
|
{locale === 'fr' ? 'Sommaire' : 'Contents'}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{doc.sections.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<a href={`#${s.id}`} className="text-white/65 hover:text-[#D4A373] transition-colors">
|
||||||
|
{s.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="space-y-12">
|
||||||
|
{doc.sections.map((s) => (
|
||||||
|
<section key={s.id} id={s.id} className="scroll-mt-24">
|
||||||
|
<h2 className="font-serif text-2xl tracking-tight mb-4 text-[#F4F1EA]">{s.title}</h2>
|
||||||
|
<div className="space-y-3 text-white/70 leading-relaxed">
|
||||||
|
{s.body.map((p, i) => (
|
||||||
|
<p key={i}>{p}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="mt-20 pt-8 border-t border-white/[0.06] text-xs text-white/35 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<span>© 2026 Memento Labs</span>
|
||||||
|
<Link href="/" className="hover:text-white transition-colors">
|
||||||
|
{locale === 'fr' ? 'Retour à l\'accueil' : 'Back to home'}
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStripeHealth(billingConfig: Record<string, string>) {
|
||||||
|
const secret = process.env.STRIPE_SECRET_KEY ?? ''
|
||||||
|
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
|
||||||
|
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
||||||
|
|
||||||
|
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
|
||||||
|
if (!secret) secretMode = 'missing'
|
||||||
|
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
|
||||||
|
else if (secret.startsWith('sk_live_')) secretMode = 'live'
|
||||||
|
else if (secret.startsWith('sk_test_')) secretMode = 'test'
|
||||||
|
else secretMode = 'placeholder'
|
||||||
|
|
||||||
|
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
|
||||||
|
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
|
||||||
|
const billingEnabled = await isBillingEnabled()
|
||||||
|
|
||||||
|
const priceKeys = [
|
||||||
|
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
|
||||||
|
] as string[]
|
||||||
|
|
||||||
|
const canCallStripe = secretMode === 'test' || secretMode === 'live'
|
||||||
|
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
|
||||||
|
if (canCallStripe) {
|
||||||
|
try {
|
||||||
|
const { getStripe } = await import('@/lib/stripe')
|
||||||
|
stripe = getStripe()
|
||||||
|
} catch {
|
||||||
|
stripe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await Promise.all(
|
||||||
|
priceKeys.map(async (key) => {
|
||||||
|
const priceId = (billingConfig[key] || process.env[key] || '').trim()
|
||||||
|
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
|
||||||
|
let stripeAmount: number | null = null
|
||||||
|
let stripeCurrency: string | null = null
|
||||||
|
let stripeActive: boolean | null = null
|
||||||
|
let error: string | null = null
|
||||||
|
|
||||||
|
if (configured && stripe) {
|
||||||
|
try {
|
||||||
|
const price = await stripe.prices.retrieve(priceId)
|
||||||
|
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
|
||||||
|
stripeCurrency = price.currency?.toUpperCase() ?? null
|
||||||
|
stripeActive = price.active
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
priceId: configured ? priceId : '',
|
||||||
|
configured,
|
||||||
|
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
|
||||||
|
stripeAmount,
|
||||||
|
stripeCurrency,
|
||||||
|
stripeActive,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
secretConfigured: secretMode === 'test' || secretMode === 'live',
|
||||||
|
secretMode,
|
||||||
|
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
|
||||||
|
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
|
||||||
|
billingEnabled,
|
||||||
|
trialDays: SUBSCRIPTION_TRIAL_DAYS,
|
||||||
|
prices,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubscriptionStats() {
|
||||||
|
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
|
||||||
|
prisma.subscription.groupBy({
|
||||||
|
by: ['tier'],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.subscription.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
|
||||||
|
prisma.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
|
||||||
|
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
select: {
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
trialEndsAt: true,
|
||||||
|
currentPeriodEnd: true,
|
||||||
|
cancelAtPeriodEnd: true,
|
||||||
|
updatedAt: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
||||||
|
for (const row of byTierRaw) {
|
||||||
|
byTier[row.tier] = row._count._all
|
||||||
|
}
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {}
|
||||||
|
for (const row of byStatusRaw) {
|
||||||
|
byStatus[row.status] = row._count._all
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithoutSub = await prisma.user.count({
|
||||||
|
where: { subscription: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
byTier,
|
||||||
|
byStatus,
|
||||||
|
cancelAtPeriodEnd,
|
||||||
|
usersWithoutSub,
|
||||||
|
trialing: byStatus.TRIALING ?? 0,
|
||||||
|
pastDue: byStatus.PAST_DUE ?? 0,
|
||||||
|
paidActive:
|
||||||
|
(byStatus.ACTIVE ?? 0) +
|
||||||
|
(byStatus.TRIALING ?? 0),
|
||||||
|
recent: recent.map((s) => ({
|
||||||
|
email: s.user.email,
|
||||||
|
name: s.user.name,
|
||||||
|
tier: s.tier,
|
||||||
|
status: s.status,
|
||||||
|
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
|
||||||
|
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
|
||||||
|
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||||
|
hasStripeSub: Boolean(s.stripeSubscriptionId),
|
||||||
|
updatedAt: s.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBillingAdminData() {
|
export async function getBillingAdminData() {
|
||||||
await checkAdmin()
|
await checkAdmin()
|
||||||
const { getSystemConfig } = await import('@/lib/config')
|
const { getSystemConfig } = await import('@/lib/config')
|
||||||
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||||
|
getStripeHealth(billingConfig),
|
||||||
|
getSubscriptionStats(),
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entitlements,
|
entitlements,
|
||||||
billingConfig,
|
billingConfig,
|
||||||
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
|
|||||||
creditAllocations,
|
creditAllocations,
|
||||||
creditCosts,
|
creditCosts,
|
||||||
creditPacks,
|
creditPacks,
|
||||||
|
stripeHealth,
|
||||||
|
subscriptionStats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { signIn } from '@/auth';
|
||||||
import { AuthError } from 'next-auth';
|
import { AuthError } from 'next-auth';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
prevState: string | undefined,
|
prevState: string | undefined,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
) {
|
) {
|
||||||
|
const emailRaw = formData.get('email');
|
||||||
|
const passwordRaw = formData.get('password');
|
||||||
|
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
|
||||||
|
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
|
||||||
|
|
||||||
|
// Surface a clear message when credentials are valid but email is unverified.
|
||||||
|
if (email && password.length >= 6) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (user?.password) {
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
if (match && !user.emailVerified) {
|
||||||
|
return 'EMAIL_NOT_VERIFIED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (preCheckErr) {
|
||||||
|
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email: formData.get('email'),
|
email,
|
||||||
password: formData.get('password'),
|
password,
|
||||||
redirectTo: '/home',
|
redirectTo: '/home',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
console.error('AuthError details:', error.type, error.message);
|
console.error('AuthError details:', error.type, error.message);
|
||||||
|
if (
|
||||||
|
error.type === 'CredentialsSignin' &&
|
||||||
|
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
|
||||||
|
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
|
||||||
|
) {
|
||||||
|
return 'EMAIL_NOT_VERIFIED';
|
||||||
|
}
|
||||||
switch (error.type) {
|
switch (error.type) {
|
||||||
case 'CredentialsSignin':
|
case 'CredentialsSignin':
|
||||||
return 'Invalid credentials.';
|
return 'Invalid credentials.';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { chatService } from '@/lib/ai/services'
|
import { chatService } from '@/lib/ai/services'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { revalidatePath } from 'next/cache'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +37,6 @@ export async function sendChatMessage(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatService.chat(message, conversationId, notebookId)
|
const result = await chatService.chat(message, conversationId, notebookId)
|
||||||
revalidatePath('/chat')
|
|
||||||
return { success: true, ...result }
|
return { success: true, ...result }
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[ChatAction] Error:', error)
|
console.error('[ChatAction] Error:', error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
|
import { sendVerificationEmail } from '@/lib/auth/email-verification';
|
||||||
|
|
||||||
const RegisterSchema = z.object({
|
const RegisterSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function register(prevState: string | undefined, formData: FormData) {
|
export async function register(prevState: string | undefined, formData: FormData) {
|
||||||
// Check if registration is allowed
|
|
||||||
const config = await getSystemConfig();
|
const config = await getSystemConfig();
|
||||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
|
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||||
|
|
||||||
if (!allowRegister) {
|
if (!allowRegister) {
|
||||||
return 'Registration is currently disabled by the administrator.';
|
return 'Registration is currently disabled by the administrator.';
|
||||||
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { email, password, name } = validatedFields.data;
|
const { email, password, name } = validatedFields.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||||
|
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return 'User already exists.';
|
return 'User already exists.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const role = isAdmin ? 'ADMIN' : 'USER';
|
||||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
|
||||||
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
|
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: email.toLowerCase(),
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
name,
|
name,
|
||||||
role,
|
role,
|
||||||
|
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
|
||||||
|
emailVerified: isAdmin ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attempt to sign in immediately after registration
|
if (!isAdmin) {
|
||||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
const mailResult = await sendVerificationEmail({
|
||||||
// but usually it works. If not, redirecting to login is fine.
|
email: normalizedEmail,
|
||||||
// Let's stick to redirecting to login but with a clear success message?
|
name,
|
||||||
// Or better: lowercase the email to fix the potential bug.
|
});
|
||||||
|
|
||||||
|
if (!mailResult.success) {
|
||||||
|
console.error('[register] verification email failed:', mailResult.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration Error:', error);
|
console.error('Registration Error:', error);
|
||||||
return 'Database Error: Failed to create user.';
|
return 'Database Error: Failed to create user.';
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect('/login');
|
if (isAdmin) {
|
||||||
|
redirect('/login?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,13 +19,11 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect user's autoLabeling toggle
|
// Respect user's autoLabeling toggle
|
||||||
@@ -61,12 +59,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = await withAiQuota(
|
const suggestions = await withAiQuota(
|
||||||
session.user.id,
|
userId,
|
||||||
'auto_tag',
|
'auto_tag',
|
||||||
() =>
|
() =>
|
||||||
autoLabelCreationService.suggestLabels(
|
autoLabelCreationService.suggestLabels(
|
||||||
notebookId,
|
notebookId,
|
||||||
session.user!.id,
|
userId,
|
||||||
language,
|
language,
|
||||||
),
|
),
|
||||||
{ lane: 'tags' },
|
{ lane: 'tags' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { batchOrganizationService } from '@/lib/ai/services'
|
import { batchOrganizationService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get language from request headers or body
|
// Get language from request headers or body
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id)
|
const userSettings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||||
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenResponse()
|
||||||
{ error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get query parameters
|
// Get query parameters
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenResponse()
|
||||||
{ error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get next insight (respects frequency limits)
|
// Get next insight (respects frequency limits)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { existingContent, resourceText, mode, language, format } = await request.json()
|
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||||
|
|||||||
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||||
|
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||||
|
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
|
||||||
|
|
||||||
|
export const maxDuration = 180
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
content: z.string().min(20),
|
||||||
|
selection: z.string().optional().nullable(),
|
||||||
|
lang: z.string().optional(),
|
||||||
|
noteId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await hasUserAiConsent())) {
|
||||||
|
return aiConsentForbiddenResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const parsed = requestSchema.parse(body)
|
||||||
|
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
|
||||||
|
const sourceRaw = parsed.selection?.trim() || parsed.content
|
||||||
|
const sourcePlain = stripHtml(sourceRaw)
|
||||||
|
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
|
||||||
|
if (wordCount < 20) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
|
||||||
|
lane: 'chat',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err instanceof QuotaServiceUnavailableError ||
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const lang = parsed.lang || 'fr'
|
||||||
|
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
|
||||||
|
const provider = getSlidesProvider(config)
|
||||||
|
|
||||||
|
const result = await generateInteractiveDemoFromContent({
|
||||||
|
content: sourceRaw,
|
||||||
|
lang,
|
||||||
|
provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const first = result.issues[0]
|
||||||
|
const detail = first
|
||||||
|
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||||
|
: ''
|
||||||
|
console.error(
|
||||||
|
'[/api/ai/interactive-demo] validation failed after repairs',
|
||||||
|
result.issues.slice(0, 10)
|
||||||
|
)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: detail
|
||||||
|
? `Démo invalide après correction — ${detail}`
|
||||||
|
: 'La génération a produit un JSON invalide après correction',
|
||||||
|
issues: result.issues.slice(0, 10),
|
||||||
|
attempts: result.attempts,
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
demo: result.demo,
|
||||||
|
attempts: result.attempts,
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Erreur génération interactive demo'
|
||||||
|
console.error('[/api/ai/interactive-demo]', error)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||||
|
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||||
|
import {
|
||||||
|
generatePagePlan,
|
||||||
|
generatePageSection,
|
||||||
|
} from '@/lib/ai/services/interactive-page-llm.service'
|
||||||
|
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||||
|
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
|
|
||||||
|
export const maxDuration = 60
|
||||||
|
|
||||||
|
const sectionPlanSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
goal: z.string().min(1),
|
||||||
|
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
||||||
|
demoGoal: z.string().nullish(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
/** undefined = legacy deterministic full-page (fallback path) */
|
||||||
|
action: z.enum(['plan', 'section']).optional(),
|
||||||
|
content: z.string().min(40).max(500_000),
|
||||||
|
lang: z.string().optional(),
|
||||||
|
noteId: z.string().optional(),
|
||||||
|
notebookId: z.string().optional(),
|
||||||
|
pageTitle: z.string().optional(),
|
||||||
|
sectionId: z.string().optional(),
|
||||||
|
section: sectionPlanSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await hasUserAiConsent())) {
|
||||||
|
return aiConsentForbiddenResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const parsed = requestSchema.parse(body)
|
||||||
|
const wordCount = parsed.content
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length
|
||||||
|
if (wordCount < 40) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getSlidesProvider(config)
|
||||||
|
const lang = parsed.lang || 'fr'
|
||||||
|
|
||||||
|
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
|
||||||
|
if (parsed.action === 'plan') {
|
||||||
|
try {
|
||||||
|
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||||
|
lane: 'chat',
|
||||||
|
amount: 5,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err instanceof QuotaServiceUnavailableError ||
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generatePagePlan({
|
||||||
|
content: parsed.content,
|
||||||
|
lang,
|
||||||
|
provider,
|
||||||
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: result.error, reason: result.reason, attempts: result.attempts },
|
||||||
|
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
|
||||||
|
if (parsed.action === 'section') {
|
||||||
|
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'section, sectionId and pageTitle are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||||
|
lane: 'chat',
|
||||||
|
amount: 3,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err instanceof QuotaServiceUnavailableError ||
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||||
|
}
|
||||||
|
const result = await generatePageSection({
|
||||||
|
content: parsed.content,
|
||||||
|
lang,
|
||||||
|
provider,
|
||||||
|
pageTitle: parsed.pageTitle,
|
||||||
|
sectionId: parsed.sectionId,
|
||||||
|
section: parsed.section,
|
||||||
|
})
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'section_generation_failed',
|
||||||
|
issues: result.issues?.slice(0, 12),
|
||||||
|
attempts: result.attempts,
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json({ section: result.section, attempts: result.attempts })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
|
||||||
|
const result = await generateInteractivePageFromContent({
|
||||||
|
content: parsed.content,
|
||||||
|
lang,
|
||||||
|
provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
if (result.error === 'unsuitable_content') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'unsuitable_content',
|
||||||
|
reason: result.reason,
|
||||||
|
attempts: result.attempts,
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const first = result.issues?.[0]
|
||||||
|
const reason =
|
||||||
|
result.reason ||
|
||||||
|
(first
|
||||||
|
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||||
|
: undefined)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
result.error === 'timeout'
|
||||||
|
? 'La génération a pris trop de temps — réessayez'
|
||||||
|
: reason ||
|
||||||
|
'La génération a produit une page invalide',
|
||||||
|
reason,
|
||||||
|
issues: result.issues?.slice(0, 12),
|
||||||
|
attempts: result.attempts,
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
page: result.page,
|
||||||
|
attempts: result.attempts,
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Requête invalide',
|
||||||
|
issues: error.issues.slice(0, 10).map((i) => ({
|
||||||
|
path: i.path.join('.'),
|
||||||
|
message: i.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
||||||
|
console.error('[/api/ai/interactive-page]', error)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await hasUserAiConsent())) {
|
||||||
|
return aiConsentForbiddenResponse()
|
||||||
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { notebookId, theme, purpose, audience, slideCount, language } = body
|
||||||
|
|
||||||
|
if (!notebookId || typeof notebookId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent =
|
||||||
|
purpose || audience || slideCount
|
||||||
|
? {
|
||||||
|
purpose: purpose || 'auto',
|
||||||
|
audience: audience || 'auto',
|
||||||
|
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
|
||||||
|
template: 'auto' as const,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const result = await withAiQuota(
|
||||||
|
userId,
|
||||||
|
'slide_generate',
|
||||||
|
() =>
|
||||||
|
generateSlideDeck({
|
||||||
|
userId,
|
||||||
|
sourceNotebookId: notebookId,
|
||||||
|
theme: theme && theme !== 'auto' ? theme : null,
|
||||||
|
lang: language === 'en' ? 'en' : 'fr',
|
||||||
|
intent,
|
||||||
|
}),
|
||||||
|
{ lane: 'chat' },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
|
console.error('[NotebookSlides] Error:', error)
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to generate slides'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { rateLimit } from '@/lib/rate-limit'
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSummaryService } from '@/lib/ai/services'
|
import { notebookSummaryService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json(
|
return aiConsentForbiddenJson()
|
||||||
{ success: false, error: 'ai_consent_required' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content, title, personaId } = await request.json()
|
const { content, title, personaId } = await request.json()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect user's paragraphRefactor toggle (Assistant IA)
|
// Respect user's paragraphRefactor toggle (Assistant IA)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
export const maxDuration = 30
|
export const maxDuration = 30
|
||||||
|
|
||||||
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
return aiConsentForbiddenResponse()
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -10,9 +10,10 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
@@ -32,11 +33,11 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestedNotebook = await withAiQuota(
|
const suggestedNotebook = await withAiQuota(
|
||||||
session.user.id,
|
userId,
|
||||||
'auto_tag',
|
'auto_tag',
|
||||||
() => notebookSuggestionService.suggestNotebook(
|
() => notebookSuggestionService.suggestNotebook(
|
||||||
noteContent,
|
noteContent,
|
||||||
session.user!.id,
|
userId,
|
||||||
language
|
language
|
||||||
),
|
),
|
||||||
{ lane: 'tags' },
|
{ lane: 'tags' },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
|||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
|
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent';
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
|
||||||
|
|
||||||
import { getAISettings } from '@/app/actions/ai-settings';
|
import { getAISettings } from '@/app/actions/ai-settings';
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
|
return aiConsentForbiddenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id);
|
const userSettings = await getAISettings(session.user.id);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getAISettings } from '@/app/actions/ai-settings'
|
|||||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||||
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getAISettings(session.user.id)
|
const settings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text } = await request.json()
|
const { text } = await request.json()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, targetLanguage } = await request.json()
|
const { text, targetLanguage } = await request.json()
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||||
|
import {
|
||||||
|
shouldOfferSubscriptionTrial,
|
||||||
|
SUBSCRIPTION_TRIAL_DAYS,
|
||||||
|
} from '@/lib/billing/trial';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
|
|||||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||||
const origin = `${proto}://${host}`;
|
const origin = `${proto}://${host}`;
|
||||||
|
|
||||||
|
const offerTrial = await shouldOfferSubscriptionTrial(userId);
|
||||||
|
const subscriptionData: {
|
||||||
|
metadata: { userId: string; tier: string };
|
||||||
|
trial_period_days?: number;
|
||||||
|
} = {
|
||||||
|
metadata: { userId, tier },
|
||||||
|
};
|
||||||
|
if (offerTrial) {
|
||||||
|
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||||
if (preferredMode === 'embedded') {
|
if (preferredMode === 'embedded') {
|
||||||
try {
|
try {
|
||||||
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
|
|||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
ui_mode: 'embedded' as any,
|
ui_mode: 'embedded' as any,
|
||||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
metadata: { userId, tier },
|
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||||
subscription_data: { metadata: { userId, tier } },
|
subscription_data: subscriptionData,
|
||||||
customer_update: { address: 'auto' },
|
customer_update: { address: 'auto' },
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
clientSecret: embedded.client_secret,
|
clientSecret: embedded.client_secret,
|
||||||
sessionId: embedded.id,
|
sessionId: embedded.id,
|
||||||
|
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (embeddedErr) {
|
} catch (embeddedErr) {
|
||||||
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
|
|||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||||
metadata: { userId, tier },
|
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||||
subscription_data: { metadata: { userId, tier } },
|
subscription_data: subscriptionData,
|
||||||
customer_update: { address: 'auto' },
|
customer_update: { address: 'auto' },
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
});
|
});
|
||||||
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
|
return NextResponse.json({
|
||||||
|
url: checkoutSession.url,
|
||||||
|
sessionId: checkoutSession.id,
|
||||||
|
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[billing/create-checkout]', error);
|
console.error('[billing/create-checkout]', error);
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||||
|
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
|
||||||
|
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -23,47 +25,16 @@ export async function GET(req: NextRequest) {
|
|||||||
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
||||||
const subId = typeof checkoutSession.subscription === 'string'
|
const subId = typeof checkoutSession.subscription === 'string'
|
||||||
? checkoutSession.subscription
|
? checkoutSession.subscription
|
||||||
: (checkoutSession.subscription as any).id;
|
: (checkoutSession.subscription as { id: string }).id;
|
||||||
|
|
||||||
const sub = await stripe.subscriptions.retrieve(subId) as any;
|
const sub = await stripe.subscriptions.retrieve(subId);
|
||||||
const priceId = sub.items.data[0].price.id;
|
const syncUserId =
|
||||||
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
|
(checkoutSession.metadata?.userId as string | undefined) ??
|
||||||
|
(sub.metadata?.userId as string | undefined) ??
|
||||||
const currentPeriodStartTimestamp =
|
userId;
|
||||||
sub.current_period_start ??
|
if (syncUserId === userId) {
|
||||||
sub.items?.data?.[0]?.current_period_start ??
|
await syncSubscriptionFromStripe(sub, userId);
|
||||||
sub.start_date ??
|
}
|
||||||
Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const currentPeriodEndTimestamp =
|
|
||||||
sub.current_period_end ??
|
|
||||||
sub.items?.data?.[0]?.current_period_end ??
|
|
||||||
(currentPeriodStartTimestamp + 30 * 24 * 3600);
|
|
||||||
|
|
||||||
await prisma.subscription.upsert({
|
|
||||||
where: { userId },
|
|
||||||
update: {
|
|
||||||
tier,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
stripeCustomerId: checkoutSession.customer as string,
|
|
||||||
stripeSubscriptionId: sub.id,
|
|
||||||
stripePriceId: priceId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
|
||||||
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
|
|
||||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId,
|
|
||||||
tier,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
stripeCustomerId: checkoutSession.customer as string,
|
|
||||||
stripeSubscriptionId: sub.id,
|
|
||||||
stripePriceId: priceId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[billing/status] Failed to sync Stripe session:', err);
|
console.error('[billing/status] Failed to sync Stripe session:', err);
|
||||||
@@ -112,6 +83,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trialEligible = await shouldOfferSubscriptionTrial(userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
tier,
|
tier,
|
||||||
effectiveTier,
|
effectiveTier,
|
||||||
@@ -120,6 +93,9 @@ export async function GET(req: NextRequest) {
|
|||||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||||
|
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
|
||||||
|
trialEligible,
|
||||||
|
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||||
prices,
|
prices,
|
||||||
creditPacks,
|
creditPacks,
|
||||||
billingEnabled,
|
billingEnabled,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
isCreditPackId,
|
isCreditPackId,
|
||||||
resolvePackFromPriceId,
|
resolvePackFromPriceId,
|
||||||
} from '@/lib/billing/credit-packs';
|
} from '@/lib/billing/credit-packs';
|
||||||
|
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'customer.subscription.trial_will_end': {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const userId = await resolveUserIdFromStripeEvent(subscription);
|
||||||
|
if (!userId) {
|
||||||
|
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
aiSettings: { select: { preferredLanguage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user?.email) break;
|
||||||
|
|
||||||
|
await syncSubscriptionFromStripe(subscription, userId);
|
||||||
|
|
||||||
|
const trialEndsAt = subscription.trial_end
|
||||||
|
? new Date(subscription.trial_end * 1000)
|
||||||
|
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
const appUrl =
|
||||||
|
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
|
||||||
|
'https://memento-note.com';
|
||||||
|
|
||||||
|
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
|
||||||
|
const mailResult = await sendTrialEndingReminder({
|
||||||
|
to: user.email,
|
||||||
|
name: user.name,
|
||||||
|
trialEndsAt,
|
||||||
|
billingUrl: `${appUrl}/settings/billing`,
|
||||||
|
locale: preferred === 'auto' ? 'en' : preferred,
|
||||||
|
});
|
||||||
|
if (!mailResult.success) {
|
||||||
|
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function GET() {
|
|||||||
const [
|
const [
|
||||||
recentNotes,
|
recentNotes,
|
||||||
inboxCount,
|
inboxCount,
|
||||||
|
inboxPreview,
|
||||||
dueFlashcards,
|
dueFlashcards,
|
||||||
upcomingReminders,
|
upcomingReminders,
|
||||||
unviewedInsights,
|
unviewedInsights,
|
||||||
@@ -83,6 +84,13 @@ export async function GET() {
|
|||||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
prisma.note.findMany({
|
||||||
|
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||||
|
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 3,
|
||||||
|
}),
|
||||||
|
|
||||||
prisma.flashcard.count({
|
prisma.flashcard.count({
|
||||||
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||||
}),
|
}),
|
||||||
@@ -177,6 +185,12 @@ export async function GET() {
|
|||||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||||
})),
|
})),
|
||||||
inboxCount,
|
inboxCount,
|
||||||
|
inboxPreview: inboxPreview.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
updatedAt: n.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
dueFlashcards,
|
dueFlashcards,
|
||||||
upcomingReminders: upcomingReminders.map(r => ({
|
upcomingReminders: upcomingReminders.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getChatProvider } from '@/lib/ai/factory'
|
|||||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
@@ -65,10 +65,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
return aiConsentForbiddenResponse()
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
|
import { extractArticleFromHtml, makeExcerpt } from '@/lib/clip/extract-article'
|
||||||
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
|
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
|
||||||
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
|
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
tags: analysis.tags,
|
tags: analysis.tags,
|
||||||
readingTime: analysis.readingTimeMinutes,
|
readingTime: analysis.readingTimeMinutes,
|
||||||
content: contentHtml,
|
content: contentHtml,
|
||||||
excerpt: textContent.slice(0, 500),
|
excerpt: makeExcerpt(textContent, 500),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[POST /api/clip/analyze]', error)
|
console.error('[POST /api/clip/analyze]', error)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
||||||
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettings = await getAISettings(session.user.id)
|
const userSettings = await getAISettings(session.user.id)
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import prisma from '@/lib/prisma'
|
|
||||||
import { auth } from '@/auth'
|
|
||||||
|
|
||||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
|
||||||
const STOPWORDS = new Set([
|
|
||||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
|
||||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
|
||||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
|
||||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
|
||||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
|
||||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
|
||||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
|
||||||
'did','will','would','could','should','may','might','this','that','these',
|
|
||||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
|
||||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
|
||||||
])
|
|
||||||
|
|
||||||
function stripHtml(html: string): string {
|
|
||||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractKeywords(text: string): Set<string> {
|
|
||||||
return new Set(
|
|
||||||
stripHtml(text)
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[\s\p{P}]+/u)
|
|
||||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
||||||
if (a.size === 0 || b.size === 0) return 0
|
|
||||||
let intersection = 0
|
|
||||||
for (const w of a) if (b.has(w)) intersection++
|
|
||||||
return intersection / (a.size + b.size - intersection)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
|
||||||
|
|
||||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
|
||||||
|
|
||||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await auth()
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const userId = session.user.id
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const notebookId = searchParams.get('notebookId') || undefined
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
|
||||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
|
||||||
select: {
|
|
||||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
|
||||||
labelRelations: { select: { id: true } },
|
|
||||||
notebook: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
take: 500,
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
|
||||||
|
|
||||||
const ids = notes.map(n => n.id)
|
|
||||||
|
|
||||||
// Query NoteLink manually created relationships
|
|
||||||
const noteLinks = await (prisma as any).noteLink.findMany({
|
|
||||||
where: {
|
|
||||||
sourceNoteId: { in: ids },
|
|
||||||
targetNoteId: { in: ids }
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
sourceNoteId: true,
|
|
||||||
targetNoteId: true,
|
|
||||||
contextSnippet: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query MemoryEchoInsight semantic relationships
|
|
||||||
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
dismissed: false,
|
|
||||||
note1Id: { in: ids },
|
|
||||||
note2Id: { in: ids }
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
note1Id: true,
|
|
||||||
note2Id: true,
|
|
||||||
similarityScore: true,
|
|
||||||
insight: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pré-calcul
|
|
||||||
const keywordsMap = new Map<string, Set<string>>()
|
|
||||||
const labelMap = new Map<string, Set<string>>()
|
|
||||||
for (const note of notes) {
|
|
||||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
|
||||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
|
|
||||||
explicit_link: 5,
|
|
||||||
semantic_echo: 4,
|
|
||||||
title_mention: 3,
|
|
||||||
shared_label: 2,
|
|
||||||
jaccard: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgeMap = new Map<string, GraphEdge>()
|
|
||||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
|
||||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
|
||||||
const ex = edgeMap.get(key)
|
|
||||||
if (!ex) {
|
|
||||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
|
||||||
} else {
|
|
||||||
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
|
|
||||||
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
|
|
||||||
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
|
|
||||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
|
||||||
for (const noteA of notes) {
|
|
||||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
|
||||||
if (title.length < 3) continue
|
|
||||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
|
||||||
for (const noteB of notes) {
|
|
||||||
if (noteA.id === noteB.id) continue
|
|
||||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
|
||||||
for (let j = i + 1; j < ids.length; j++) {
|
|
||||||
const la = labelMap.get(ids[i])!
|
|
||||||
const lb = labelMap.get(ids[j])!
|
|
||||||
const shared = [...la].filter(l => lb.has(l)).length
|
|
||||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
|
||||||
if (notes.length <= 500) {
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
|
||||||
const kwI = keywordsMap.get(ids[i])!
|
|
||||||
const candidates: { j: number; score: number }[] = []
|
|
||||||
for (let j = i + 1; j < ids.length; j++) {
|
|
||||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
|
||||||
if (score >= 0.12) candidates.push({ j, score })
|
|
||||||
}
|
|
||||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
|
||||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
|
|
||||||
for (const link of noteLinks) {
|
|
||||||
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
|
|
||||||
for (const echo of echoInsights) {
|
|
||||||
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
|
|
||||||
}
|
|
||||||
|
|
||||||
const degreeMap = new Map<string, number>()
|
|
||||||
for (const e of edgeMap.values()) {
|
|
||||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
|
||||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = notes.map(n => ({
|
|
||||||
id: n.id,
|
|
||||||
title: n.title || 'Sans titre',
|
|
||||||
notebookId: n.notebookId,
|
|
||||||
createdAt: n.createdAt,
|
|
||||||
degree: degreeMap.get(n.id) ?? 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Build clusters (notebooks)
|
|
||||||
const notebookMap = new Map<string, string>()
|
|
||||||
for (const n of notes) {
|
|
||||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
|
||||||
}
|
|
||||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
|
||||||
|
|
||||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import prisma from '@/lib/prisma'
|
|
||||||
import { auth } from '@/auth'
|
|
||||||
|
|
||||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
|
||||||
|
|
||||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
|
||||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
|
||||||
const results: { title: string; snippet: string }[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
let match: RegExpExecArray | null
|
|
||||||
|
|
||||||
WIKILINK_RE.lastIndex = 0
|
|
||||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
|
||||||
const title = match[1].trim()
|
|
||||||
if (!title || seen.has(title.toLowerCase())) continue
|
|
||||||
seen.add(title.toLowerCase())
|
|
||||||
const start = Math.max(0, match.index - 50)
|
|
||||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
|
||||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
|
||||||
results.push({ title, snippet })
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/graph/sync-all
|
|
||||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
|
||||||
* Call once to populate the NoteLink table from existing notes.
|
|
||||||
*/
|
|
||||||
export async function POST() {
|
|
||||||
const session = await auth()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
const userId = session.user.id
|
|
||||||
|
|
||||||
// Get all non-trashed notes with content
|
|
||||||
const notes = await prisma.note.findMany({
|
|
||||||
where: { userId, trashedAt: null, content: { not: '' } },
|
|
||||||
select: { id: true, content: true, notebookId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
let totalLinks = 0
|
|
||||||
|
|
||||||
for (const note of notes) {
|
|
||||||
if (!note.content.includes('[[')) continue
|
|
||||||
|
|
||||||
const wikilinks = extractWikilinks(note.content)
|
|
||||||
if (wikilinks.length === 0) continue
|
|
||||||
|
|
||||||
for (const { title, snippet } of wikilinks) {
|
|
||||||
const targetNote = await prisma.note.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
title: { equals: title, mode: 'insensitive' },
|
|
||||||
trashedAt: null,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!targetNote) {
|
|
||||||
// Skip stubs in batch sync — we only link existing notes
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetNote.id === note.id) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
await (prisma as any).noteLink.upsert({
|
|
||||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
|
||||||
update: { contextSnippet: snippet },
|
|
||||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
|
||||||
})
|
|
||||||
totalLinks++
|
|
||||||
} catch {
|
|
||||||
// ignore duplicate constraint errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,13 @@ import prisma from '@/lib/prisma'
|
|||||||
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
||||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
|
||||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||||
|
import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||||
|
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||||
|
|
||||||
const MODERATION_TIMEOUT_MS = 12_000
|
const MODERATION_TIMEOUT_MS = 12_000
|
||||||
|
|
||||||
@@ -88,18 +92,69 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All human-facing text of a PageSpecV1 — fed to moderation. */
|
||||||
|
function collectPageText(page: PageSpecV1): string[] {
|
||||||
|
const out: string[] = [
|
||||||
|
page.hero.kicker,
|
||||||
|
page.hero.title,
|
||||||
|
page.hero.subtitle ?? '',
|
||||||
|
page.hero.meta ?? '',
|
||||||
|
page.footer ?? '',
|
||||||
|
]
|
||||||
|
if (page.overview) {
|
||||||
|
out.push(page.overview.lead)
|
||||||
|
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
|
||||||
|
}
|
||||||
|
for (const section of page.sections) {
|
||||||
|
out.push(section.title)
|
||||||
|
for (const b of section.blocks) {
|
||||||
|
if (b.type === 'prose') out.push(b.md)
|
||||||
|
else if (b.type === 'formula') out.push(b.caption ?? '')
|
||||||
|
else if (b.type === 'callout') out.push(b.title, b.md)
|
||||||
|
else if (b.type === 'demo') {
|
||||||
|
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
|
||||||
|
for (const act of b.demo.acts) {
|
||||||
|
out.push(act.title)
|
||||||
|
for (const st of act.steps) out.push(st.speak)
|
||||||
|
}
|
||||||
|
for (const panel of b.demo.scene.panels) {
|
||||||
|
if (panel.type === 'svg-scene') {
|
||||||
|
for (const n of panel.payload.nodes) out.push(n.label ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (b.type === 'chart') {
|
||||||
|
out.push(b.caption ?? '')
|
||||||
|
for (const s of b.payload.series) out.push(s.label ?? '')
|
||||||
|
} else if (b.type === 'stats') {
|
||||||
|
for (const it of b.items) out.push(it.value, it.label)
|
||||||
|
} else if (b.type === 'table') {
|
||||||
|
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
|
||||||
|
} else if (b.type === 'image') {
|
||||||
|
out.push(b.alt, b.caption ?? '')
|
||||||
|
} else if (b.type === 'sim') {
|
||||||
|
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((s) => s && s.trim())
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json().catch(() => null)
|
||||||
const { noteId, action, mode, template, language, rewrite } = body as {
|
if (!body || typeof body !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
|
||||||
noteId?: string
|
noteId?: string
|
||||||
action?: string
|
action?: string
|
||||||
mode?: 'simple' | 'ai'
|
mode?: 'simple' | 'ai' | 'interactive-page'
|
||||||
template?: string
|
template?: string
|
||||||
language?: string
|
language?: string
|
||||||
rewrite?: boolean
|
rewrite?: boolean
|
||||||
|
pageSpec?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||||
@@ -111,13 +166,98 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
if (action === 'publish') {
|
if (action === 'publish') {
|
||||||
|
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
|
||||||
|
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
|
||||||
|
if (!(await hasUserAiConsent())) {
|
||||||
|
return aiConsentForbiddenResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedPage: PageValidationResult | null = null
|
||||||
|
if (pageSpec) {
|
||||||
|
// Client-provided page (from the preview dialog): must validate as-is.
|
||||||
|
// Never silently substitute a different page than the one previewed.
|
||||||
|
const checked = validateInteractivePage(pageSpec)
|
||||||
|
if (!checked.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
validatedPage = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validatedPage) {
|
||||||
|
// Deterministic generate (no LLM quota)
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getSlidesProvider(config)
|
||||||
|
const generated = await generateInteractivePageFromContent({
|
||||||
|
content: note.content || '',
|
||||||
|
lang: language || 'fr',
|
||||||
|
provider,
|
||||||
|
})
|
||||||
|
if (!generated.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: generated.error || 'interactive_page_generation_failed',
|
||||||
|
reason: generated.reason,
|
||||||
|
issues: generated.issues?.slice(0, 12),
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
validatedPage = { ok: true as const, page: generated.page }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderate ALL human-facing text of the page (prose, callouts, demo
|
||||||
|
// narration, tables, captions) — not just titles.
|
||||||
|
const textForModeration = collectPageText(validatedPage.page).join('\n')
|
||||||
|
|
||||||
|
const moderation = await moderateWithFallback(
|
||||||
|
note.title || '',
|
||||||
|
textForModeration
|
||||||
|
)
|
||||||
|
if (moderation.verdict === 'blocked') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'blocked',
|
||||||
|
reason: moderation.reason,
|
||||||
|
categories: moderation.categories,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (moderation.verdict === 'flagged') {
|
||||||
|
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||||
|
const sourceHash = computePublishedSourceHash(note.content || '')
|
||||||
|
|
||||||
|
await updateNotePublishState(noteId, {
|
||||||
|
isPublic: true,
|
||||||
|
publicSlug: slug,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
publishedContent: JSON.stringify(validatedPage.page),
|
||||||
|
publishedTemplate: 'interactive-page',
|
||||||
|
publishedSourceHash: sourceHash,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
slug,
|
||||||
|
mode: 'interactive-page',
|
||||||
|
template: 'interactive-page',
|
||||||
|
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
||||||
|
|
||||||
if (publishMode === 'ai') {
|
if (publishMode === 'ai') {
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
return aiConsentForbiddenResponse()
|
||||||
}
|
}
|
||||||
if (!template || !isPublishTemplateId(template)) {
|
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
|
||||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default async function RootLayout({
|
|||||||
const htmlStyle = {
|
const htmlStyle = {
|
||||||
'--color-brand-accent': serverAccent,
|
'--color-brand-accent': serverAccent,
|
||||||
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
||||||
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
colorScheme: wantsDarkClass ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
|
||||||
|
|
||||||
export default function TestTitleSuggestionsPage() {
|
|
||||||
const [content, setContent] = useState('')
|
|
||||||
|
|
||||||
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
|
|
||||||
content,
|
|
||||||
enabled: true // Always enabled for testing
|
|
||||||
})
|
|
||||||
|
|
||||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
|
||||||
<h1>Test Title Suggestions</h1>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<label>Content (need 50+ words):</label>
|
|
||||||
<textarea
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
style={{ width: '100%', height: '200px', marginTop: '10px' }}
|
|
||||||
placeholder="Type at least 50 words here..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
|
|
||||||
<p><strong>Word count:</strong> {wordCount} / 50</p>
|
|
||||||
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
|
|
||||||
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Suggestions ({suggestions.length}):</h2>
|
|
||||||
{suggestions.length > 0 ? (
|
|
||||||
<ul>
|
|
||||||
{suggestions.map((s, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
<strong>{s.title}</strong> (confidence: {s.confidence}%)
|
|
||||||
{s.reasoning && <p>→ {s.reasoning}</p>}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '20px' }}>
|
|
||||||
<button onClick={() => {
|
|
||||||
setContent('word '.repeat(50))
|
|
||||||
}}>
|
|
||||||
Fill with 50 words (test)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,20 +23,19 @@ export const authConfig = {
|
|||||||
nextUrl.pathname.startsWith('/settings') ||
|
nextUrl.pathname.startsWith('/settings') ||
|
||||||
nextUrl.pathname.startsWith('/lab') ||
|
nextUrl.pathname.startsWith('/lab') ||
|
||||||
nextUrl.pathname.startsWith('/agents') ||
|
nextUrl.pathname.startsWith('/agents') ||
|
||||||
nextUrl.pathname.startsWith('/chat') ||
|
|
||||||
nextUrl.pathname.startsWith('/canvas') ||
|
nextUrl.pathname.startsWith('/canvas') ||
|
||||||
nextUrl.pathname.startsWith('/notebooks') ||
|
nextUrl.pathname.startsWith('/notebooks') ||
|
||||||
nextUrl.pathname.startsWith('/note/') ||
|
nextUrl.pathname.startsWith('/note/') ||
|
||||||
nextUrl.pathname.startsWith('/brainstorm') ||
|
nextUrl.pathname.startsWith('/brainstorm') ||
|
||||||
nextUrl.pathname.startsWith('/insights') ||
|
nextUrl.pathname.startsWith('/insights') ||
|
||||||
nextUrl.pathname.startsWith('/graph') ||
|
nextUrl.pathname.startsWith('/revision');
|
||||||
nextUrl.pathname.startsWith('/revision') ||
|
|
||||||
nextUrl.pathname.startsWith('/support');
|
|
||||||
const isAdminPage = nextUrl.pathname.startsWith('/admin');
|
const isAdminPage = nextUrl.pathname.startsWith('/admin');
|
||||||
const isPublicPage = nextUrl.pathname === '/' ||
|
const isPublicPage = nextUrl.pathname === '/' ||
|
||||||
nextUrl.pathname === '/login' ||
|
nextUrl.pathname === '/login' ||
|
||||||
nextUrl.pathname === '/register' ||
|
nextUrl.pathname === '/register' ||
|
||||||
nextUrl.pathname === '/forgot-password' ||
|
nextUrl.pathname === '/forgot-password' ||
|
||||||
|
nextUrl.pathname === '/check-email' ||
|
||||||
|
nextUrl.pathname === '/verify-email' ||
|
||||||
nextUrl.pathname.startsWith('/reset-password');
|
nextUrl.pathname.startsWith('/reset-password');
|
||||||
|
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
|||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
pollRef.current = null
|
pollRef.current = null
|
||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
|
toast.error(t('agents.toasts.runError', { error: data.errorKey ? t(data.errorKey) : (data.error || t('agents.toasts.runFailed')) }), {
|
||||||
id: toastId,
|
id: toastId,
|
||||||
description: '' // Clear the loading description
|
description: '' // Clear the loading description
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AutoLabelSuggestionDialog({
|
|||||||
onLabelsCreated()
|
onLabelsCreated()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('ai.autoLabels.error'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.autoLabels.error')))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create labels:', error)
|
console.error('Failed to create labels:', error)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function BatchOrganizationDialog({
|
|||||||
onNotesMoved()
|
onNotesMoved()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.batchOrganization.applyFailed')))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to apply organization plan:', error)
|
console.error('Failed to apply organization plan:', error)
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useChat } from '@ai-sdk/react'
|
|
||||||
import { DefaultChatTransport } from 'ai'
|
|
||||||
import { ChatSidebar } from './chat-sidebar'
|
|
||||||
import { ChatMessages } from './chat-messages'
|
|
||||||
import { ChatInput } from './chat-input'
|
|
||||||
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import type { UIMessage } from 'ai'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface ChatContainerProps {
|
|
||||||
initialConversations: any[]
|
|
||||||
notebooks: any[]
|
|
||||||
webSearchAvailable?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
|
|
||||||
const { t, language } = useLanguage()
|
|
||||||
const [conversations, setConversations] = useState(initialConversations)
|
|
||||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
|
||||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
|
||||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
|
||||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
|
||||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
|
||||||
|
|
||||||
// Prevents the useEffect from loading an empty conversation
|
|
||||||
// when we just created one via createConversation()
|
|
||||||
const skipHistoryLoad = useRef(false)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const transport = useRef(new DefaultChatTransport({
|
|
||||||
api: '/api/chat',
|
|
||||||
})).current
|
|
||||||
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
status,
|
|
||||||
setMessages,
|
|
||||||
stop,
|
|
||||||
} = useChat({
|
|
||||||
transport,
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || t('chat.assistantError'))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading = status === 'submitted' || status === 'streaming'
|
|
||||||
|
|
||||||
const refreshConversations = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const updated = await getConversations()
|
|
||||||
setConversations(updated)
|
|
||||||
} catch {}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Timeout warning: show toast if response takes > 30s
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) return
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
|
||||||
}, 30000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [isLoading, t])
|
|
||||||
|
|
||||||
// Sync historyMessages after each completed streaming response
|
|
||||||
// so the display doesn't revert to stale history.
|
|
||||||
// Also refresh sidebar so new conversation appears.
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'ready' && messages.length > 0) {
|
|
||||||
setHistoryMessages([...messages])
|
|
||||||
refreshConversations()
|
|
||||||
}
|
|
||||||
}, [status, messages, refreshConversations])
|
|
||||||
|
|
||||||
// Load conversation details when the user selects a different conversation
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip if we just created the conversation — useChat already has the messages
|
|
||||||
if (skipHistoryLoad.current) {
|
|
||||||
skipHistoryLoad.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentId) {
|
|
||||||
const loadMessages = async () => {
|
|
||||||
setIsLoadingHistory(true)
|
|
||||||
try {
|
|
||||||
const details = await getConversationDetails(currentId)
|
|
||||||
if (details) {
|
|
||||||
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
|
|
||||||
id: m.id || `hist-${i}`,
|
|
||||||
role: m.role as 'user' | 'assistant',
|
|
||||||
parts: [{ type: 'text' as const, text: m.content }],
|
|
||||||
}))
|
|
||||||
setHistoryMessages(loaded)
|
|
||||||
setMessages(loaded)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('chat.loadError'))
|
|
||||||
} finally {
|
|
||||||
setIsLoadingHistory(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadMessages()
|
|
||||||
} else {
|
|
||||||
setMessages([])
|
|
||||||
setHistoryMessages([])
|
|
||||||
}
|
|
||||||
|
|
||||||
}, [currentId])
|
|
||||||
|
|
||||||
const handleSendMessage = async (content: string, notebookId?: string) => {
|
|
||||||
if (notebookId) {
|
|
||||||
setSelectedNotebook(notebookId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no active conversation, create one BEFORE streaming
|
|
||||||
let convId = currentId
|
|
||||||
if (!convId) {
|
|
||||||
try {
|
|
||||||
const result = await createConversation(content, notebookId || selectedNotebook)
|
|
||||||
convId = result.id
|
|
||||||
// Tell the useEffect to skip — we don't want to load an empty conversation
|
|
||||||
skipHistoryLoad.current = true
|
|
||||||
setCurrentId(convId)
|
|
||||||
setHistoryMessages([])
|
|
||||||
setConversations((prev) => [
|
|
||||||
{ id: result.id, title: result.title, updatedAt: new Date() },
|
|
||||||
...prev,
|
|
||||||
])
|
|
||||||
} catch {
|
|
||||||
toast.error(t('chat.createError'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendMessage(
|
|
||||||
{ text: content },
|
|
||||||
{
|
|
||||||
body: {
|
|
||||||
conversationId: convId,
|
|
||||||
notebookId: notebookId || selectedNotebook || undefined,
|
|
||||||
language,
|
|
||||||
webSearch: webSearchEnabled,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Chat send error:', error)
|
|
||||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewChat = () => {
|
|
||||||
setCurrentId(null)
|
|
||||||
setMessages([])
|
|
||||||
setHistoryMessages([])
|
|
||||||
setSelectedNotebook(undefined)
|
|
||||||
setWebSearchEnabled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteConversation = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteConversation(id)
|
|
||||||
if (currentId === id) {
|
|
||||||
handleNewChat()
|
|
||||||
}
|
|
||||||
await refreshConversations()
|
|
||||||
} catch {
|
|
||||||
toast.error(t('chat.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// During streaming or if useChat has more messages than history, prefer useChat
|
|
||||||
const displayMessages = isLoading || messages.length > historyMessages.length
|
|
||||||
? messages
|
|
||||||
: historyMessages
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when messages change
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}, [displayMessages])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex overflow-hidden bg-background">
|
|
||||||
<ChatSidebar
|
|
||||||
conversations={conversations}
|
|
||||||
currentId={currentId}
|
|
||||||
onSelect={setCurrentId}
|
|
||||||
onNew={handleNewChat}
|
|
||||||
onDelete={handleDeleteConversation}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
|
||||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
|
|
||||||
<div className="w-full max-w-4xl px-4">
|
|
||||||
<ChatInput
|
|
||||||
onSend={handleSendMessage}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onStop={stop}
|
|
||||||
notebooks={notebooks}
|
|
||||||
currentNotebookId={selectedNotebook || null}
|
|
||||||
webSearchEnabled={webSearchEnabled}
|
|
||||||
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
|
|
||||||
webSearchAvailable={webSearchAvailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
onSend: (message: string, notebookId?: string) => void
|
|
||||||
isLoading?: boolean
|
|
||||||
onStop?: () => void
|
|
||||||
notebooks: any[]
|
|
||||||
currentNotebookId?: string | null
|
|
||||||
webSearchEnabled?: boolean
|
|
||||||
onToggleWebSearch?: () => void
|
|
||||||
webSearchAvailable?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentNotebookId) {
|
|
||||||
setSelectedNotebook(currentNotebookId)
|
|
||||||
}
|
|
||||||
}, [currentNotebookId])
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (!input.trim() || isLoading) return
|
|
||||||
onSend(input, selectedNotebook)
|
|
||||||
setInput('')
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
|
||||||
}
|
|
||||||
}, [input])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full relative">
|
|
||||||
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
placeholder={t('chat.placeholder')}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom Actions Bar */}
|
|
||||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
|
||||||
{/* Context Selector */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedNotebook || 'global'}
|
|
||||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
|
|
||||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
|
|
||||||
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
|
|
||||||
{notebooks.map((nb) => (
|
|
||||||
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
|
|
||||||
{(() => {
|
|
||||||
const Icon = getNotebookIcon(nb.icon)
|
|
||||||
return <Icon className="w-3.5 h-3.5" />
|
|
||||||
})()} {nb.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{selectedNotebook && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
|
||||||
{t('chat.active')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webSearchAvailable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleWebSearch}
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
|
||||||
webSearchEnabled
|
|
||||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
|
||||||
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Globe className="h-3.5 w-3.5" />
|
|
||||||
{webSearchEnabled && t('chat.webSearch')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Send / Stop Button */}
|
|
||||||
{isLoading && onStop ? (
|
|
||||||
<Button
|
|
||||||
onClick={onStop}
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Square className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
disabled={!input.trim() || isLoading}
|
|
||||||
onClick={handleSend}
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"rounded-full h-8 w-8 transition-all duration-200",
|
|
||||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 ml-0.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mt-3">
|
|
||||||
<span className="text-[11px] text-muted-foreground/60 w-full block">
|
|
||||||
{t('chat.disclaimer')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { User, Bot, Loader2 } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface ChatMessagesProps {
|
|
||||||
messages: any[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageContent(msg: any): string {
|
|
||||||
if (typeof msg.content === 'string') return msg.content
|
|
||||||
if (msg.parts && Array.isArray(msg.parts)) {
|
|
||||||
return msg.parts
|
|
||||||
.filter((p: any) => p.type === 'text')
|
|
||||||
.map((p: any) => p.text)
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
|
|
||||||
{messages.length === 0 && !isLoading && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
|
|
||||||
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
|
|
||||||
<Bot className="h-12 w-12 text-primary opacity-60" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
|
|
||||||
{t('chat.welcome')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((msg, index) => {
|
|
||||||
const content = getMessageContent(msg)
|
|
||||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={msg.id || index}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full px-4 md:px-0 py-6 my-2 group",
|
|
||||||
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{msg.role === 'user' ? (
|
|
||||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
|
|
||||||
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
|
|
||||||
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div dir="auto" className="flex-1 overflow-hidden pt-1">
|
|
||||||
{content ? (
|
|
||||||
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : isLastAssistant ? (
|
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
||||||
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
|
||||||
import { fr } from 'date-fns/locale/fr'
|
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
|
||||||
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
|
||||||
conversations: any[]
|
|
||||||
currentId?: string | null
|
|
||||||
onSelect: (id: string) => void
|
|
||||||
onNew: () => void
|
|
||||||
onDelete?: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatSidebar({
|
|
||||||
conversations,
|
|
||||||
currentId,
|
|
||||||
onSelect,
|
|
||||||
onNew,
|
|
||||||
onDelete,
|
|
||||||
}: ChatSidebarProps) {
|
|
||||||
const { t, language } = useLanguage()
|
|
||||||
const dateLocale = language === 'fr' ? fr : enUS
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const confirmDelete = (id: string) => {
|
|
||||||
setPendingDelete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setPendingDelete(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const executeDelete = async (e: React.MouseEvent, id: string) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setPendingDelete(null)
|
|
||||||
if (onDelete) {
|
|
||||||
await onDelete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
|
|
||||||
<div className="p-4 border-bottom">
|
|
||||||
<Button
|
|
||||||
onClick={onNew}
|
|
||||||
className="w-full justify-start gap-2 shadow-sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{t('chat.newConversation')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
|
||||||
{conversations.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
||||||
{t('chat.noHistory')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
conversations.map((chat) => (
|
|
||||||
<div
|
|
||||||
key={chat.id}
|
|
||||||
onClick={() => onSelect(chat.id)}
|
|
||||||
className={cn(
|
|
||||||
"relative cursor-pointer rounded-lg transition-all group",
|
|
||||||
currentId === chat.id
|
|
||||||
? "bg-primary/10 text-primary dark:bg-primary/20"
|
|
||||||
: "hover:bg-muted/50 text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-3 flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate text-sm font-medium pr-6">
|
|
||||||
{chat.title || t('chat.untitled')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
|
|
||||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete button — visible on hover or when confirming */}
|
|
||||||
{pendingDelete !== chat.id && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
|
|
||||||
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inline confirmation banner */}
|
|
||||||
{pendingDelete === chat.id && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => executeDelete(e, chat.id)}
|
|
||||||
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
|
|
||||||
>
|
|
||||||
{t('chat.yes')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelDelete}
|
|
||||||
className="p-0.5 hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -345,7 +345,7 @@ export function ContextualAIChat({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
const descs = data.descriptions || []
|
const descs = data.descriptions || []
|
||||||
let resultText = descs.map((d: any) =>
|
let resultText = descs.map((d: any) =>
|
||||||
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
|
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
|
||||||
@@ -381,7 +381,7 @@ export function ContextualAIChat({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
const result = data[action.resultKey] || ''
|
const result = data[action.resultKey] || ''
|
||||||
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -457,7 +457,7 @@ export function ContextualAIChat({
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok || !data.success) {
|
if (!res.ok || !data.success) {
|
||||||
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
|
mToast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.errorShort')), { id: toastId })
|
||||||
setGenerateLoading(null)
|
setGenerateLoading(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -577,7 +577,7 @@ export function ContextualAIChat({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.resource.enrichError')))
|
||||||
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
mToast.error(e.message || t('ai.resource.enrichError'))
|
mToast.error(e.message || t('ai.resource.enrichError'))
|
||||||
@@ -622,7 +622,7 @@ export function ContextualAIChat({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||||
setResourcePreview({ text: data.enrichedContent, source: mode })
|
setResourcePreview({ text: data.enrichedContent, source: mode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -35,12 +35,22 @@ export function DashboardAgentCarousel({
|
|||||||
}: DashboardAgentCarouselProps) {
|
}: DashboardAgentCarouselProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [idx, setIdx] = useState(0)
|
const [idx, setIdx] = useState(0)
|
||||||
|
const directionRef = useRef(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 navActions = suggestions.length > 1 ? (
|
const navActions = suggestions.length > 1 ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIdx(i => Math.max(0, i - 1))}
|
onClick={goPrev}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
aria-label={t('homeDashboard.intelPrev')}
|
aria-label={t('homeDashboard.intelPrev')}
|
||||||
@@ -52,7 +62,7 @@ export function DashboardAgentCarousel({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
|
onClick={goNext}
|
||||||
disabled={idx >= suggestions.length - 1}
|
disabled={idx >= suggestions.length - 1}
|
||||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
aria-label={t('homeDashboard.intelNext')}
|
aria-label={t('homeDashboard.intelNext')}
|
||||||
@@ -84,6 +94,7 @@ export function DashboardAgentCarousel({
|
|||||||
) : (
|
) : (
|
||||||
<AgentSlide
|
<AgentSlide
|
||||||
current={suggestions[idx]}
|
current={suggestions[idx]}
|
||||||
|
direction={directionRef.current}
|
||||||
actingId={actingId}
|
actingId={actingId}
|
||||||
formatFrequency={formatFrequency}
|
formatFrequency={formatFrequency}
|
||||||
onAccept={onAccept}
|
onAccept={onAccept}
|
||||||
@@ -98,6 +109,7 @@ export function DashboardAgentCarousel({
|
|||||||
|
|
||||||
function AgentSlide({
|
function AgentSlide({
|
||||||
current,
|
current,
|
||||||
|
direction,
|
||||||
actingId,
|
actingId,
|
||||||
formatFrequency,
|
formatFrequency,
|
||||||
onAccept,
|
onAccept,
|
||||||
@@ -106,6 +118,7 @@ function AgentSlide({
|
|||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
current: AgentSuggestion
|
current: AgentSuggestion
|
||||||
|
direction: number
|
||||||
actingId: string | null
|
actingId: string | null
|
||||||
formatFrequency: (f: string) => string
|
formatFrequency: (f: string) => string
|
||||||
onAccept: (id: string) => void
|
onAccept: (id: string) => void
|
||||||
@@ -115,7 +128,11 @@ function AgentSlide({
|
|||||||
}) {
|
}) {
|
||||||
const slide = prefersReducedMotion
|
const slide = prefersReducedMotion
|
||||||
? { initial: {}, animate: {}, exit: {} }
|
? { initial: {}, animate: {}, exit: {} }
|
||||||
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
|
: {
|
||||||
|
initial: { opacity: 0, x: 14 * direction },
|
||||||
|
animate: { opacity: 1, x: 0 },
|
||||||
|
exit: { opacity: 0, x: -14 * direction },
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||||
@@ -42,14 +43,19 @@ export function DashboardWidgetShell({
|
|||||||
|
|
||||||
export function DashboardInboxWidget({
|
export function DashboardInboxWidget({
|
||||||
count,
|
count,
|
||||||
|
notes,
|
||||||
loading,
|
loading,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
count: number
|
count: number
|
||||||
|
notes: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||||
loading: boolean
|
loading: boolean
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const reduced = !!useReducedMotion()
|
||||||
return (
|
return (
|
||||||
<DashboardWidgetShell
|
<DashboardWidgetShell
|
||||||
widgetId="inbox"
|
widgetId="inbox"
|
||||||
@@ -58,19 +64,42 @@ export function DashboardInboxWidget({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="space-y-2">
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="space-y-1.5">
|
||||||
type="button"
|
{notes.map((note, idx) => (
|
||||||
onClick={onOpen}
|
<motion.button
|
||||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
key={note.id}
|
||||||
>
|
type="button"
|
||||||
<div>
|
onClick={() => onSelect(note.id, note.notebookId)}
|
||||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
initial={reduced ? false : { opacity: 0, x: 8 }}
|
||||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
animate={{ opacity: 1, x: 0 }}
|
||||||
</div>
|
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
|
||||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
|
||||||
</button>
|
>
|
||||||
|
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
|
||||||
|
{note.title || t('homeDashboard.untitled')}
|
||||||
|
</p>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-1 pt-1 text-start"
|
||||||
|
>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-concrete">
|
||||||
|
{t('homeDashboard.inboxSeeAll', { count })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">
|
||||||
|
{t('homeDashboard.widgetOpen')} →
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</DashboardWidgetShell>
|
</DashboardWidgetShell>
|
||||||
)
|
)
|
||||||
@@ -94,7 +123,7 @@ export function DashboardRevisionWidget({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,7 +164,7 @@ export function DashboardStatsWidget({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
|
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={onOpen} className="w-full text-start">
|
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||||
@@ -176,8 +205,8 @@ export function DashboardAgentActivityWidget({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
) : actions.length === 0 ? (
|
) : actions.length === 0 ? (
|
||||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
||||||
@@ -222,7 +251,7 @@ export function DashboardGmailWidget({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
) : connected ? (
|
) : connected ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -264,7 +293,7 @@ export function DashboardPinnedWidget({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
) : notes.length === 0 ? (
|
) : notes.length === 0 ? (
|
||||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||||
|
|||||||
@@ -19,12 +19,22 @@ interface BridgeNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||||
|
const EASE = [0.16, 1, 0.3, 1] as const
|
||||||
|
|
||||||
|
function clusterPoint(index: number, count: number): { x: number; y: number } {
|
||||||
|
if (count === 1) return { x: 50, y: 42 }
|
||||||
|
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
|
||||||
|
const rx = count <= 3 ? 28 : 36
|
||||||
|
const ry = count <= 3 ? 24 : 30
|
||||||
|
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardMindOrbitProps {
|
export interface DashboardMindOrbitProps {
|
||||||
clusters: Cluster[]
|
clusters: Cluster[]
|
||||||
bridgeNotes: BridgeNote[]
|
bridgeNotes: BridgeNote[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
onOpenInsights: () => void
|
onOpenInsights: () => void
|
||||||
|
onOpenCluster?: (clusterId: number) => void
|
||||||
onNoteSelect: (id: string) => void
|
onNoteSelect: (id: string) => void
|
||||||
prefersReducedMotion?: boolean
|
prefersReducedMotion?: boolean
|
||||||
}
|
}
|
||||||
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
|
|||||||
bridgeNotes,
|
bridgeNotes,
|
||||||
loading,
|
loading,
|
||||||
onOpenInsights,
|
onOpenInsights,
|
||||||
|
onOpenCluster,
|
||||||
onNoteSelect,
|
onNoteSelect,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
}: DashboardMindOrbitProps) {
|
}: DashboardMindOrbitProps) {
|
||||||
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
const maxCount = topClusters[0]?.noteIds.length || 1
|
const maxCount = topClusters[0]?.noteIds.length || 1
|
||||||
const topBridge = bridgeNotes[0]
|
const topBridge = bridgeNotes[0]
|
||||||
|
const reduced = !!prefersReducedMotion
|
||||||
|
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
@@ -80,21 +93,64 @@ export function DashboardMindOrbit({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
|
<motion.div
|
||||||
|
className="relative h-[176px]"
|
||||||
|
initial={reduced ? false : { clipPath: 'circle(0% at 50% 44%)' }}
|
||||||
|
animate={{ clipPath: 'circle(120% at 50% 44%)' }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.62, ease: EASE }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{points.map((point, idx) => {
|
||||||
|
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
|
||||||
|
return (
|
||||||
|
<motion.path
|
||||||
|
key={`spoke-${topClusters[idx].clusterId}`}
|
||||||
|
d={`M 50 44 L ${point.x} ${point.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={0.7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 0.4 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
{topClusters.map((cluster, idx) => {
|
{topClusters.map((cluster, idx) => {
|
||||||
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||||
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
||||||
const size = Math.round(56 * scale)
|
const size = Math.round(52 * scale)
|
||||||
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||||
|
const point = points[idx]
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={cluster.clusterId}
|
key={cluster.clusterId}
|
||||||
type="button"
|
type="button"
|
||||||
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
|
whileHover={reduced ? undefined : { scale: 1.06 }}
|
||||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||||
onClick={onOpenInsights}
|
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
|
||||||
className="relative flex flex-col items-center gap-1.5 group"
|
className="absolute flex flex-col items-center gap-1 group"
|
||||||
style={{ width: size + 16 }}
|
style={{
|
||||||
|
left: `${point.x}%`,
|
||||||
|
top: `${point.y}%`,
|
||||||
|
width: size + 16,
|
||||||
|
}}
|
||||||
|
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
|
||||||
|
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
||||||
@@ -114,13 +170,16 @@ export function DashboardMindOrbit({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{topBridge?.note && (
|
{topBridge?.note && (
|
||||||
<button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNoteSelect(topBridge.noteId)}
|
onClick={() => onNoteSelect(topBridge.noteId)}
|
||||||
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
||||||
|
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
|
||||||
>
|
>
|
||||||
<Zap size={11} className="text-brand-accent shrink-0" />
|
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -132,7 +191,7 @@ export function DashboardMindOrbit({
|
|||||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
{Math.round(topBridge.bridgeScore * 100)}%
|
{Math.round(topBridge.bridgeScore * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
|
|||||||
onAction,
|
onAction,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
}: DashboardNextPathsProps) {
|
}: DashboardNextPathsProps) {
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
|
const rtl = language === 'ar' || language === 'fa'
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
|
|||||||
const hero = paths[0]
|
const hero = paths[0]
|
||||||
const rest = paths.slice(1, 5)
|
const rest = paths.slice(1, 5)
|
||||||
const HeroIcon = TYPE_META[hero.type].Icon
|
const HeroIcon = TYPE_META[hero.type].Icon
|
||||||
|
const reduced = !!prefersReducedMotion
|
||||||
|
const ease = [0.16, 1, 0.3, 1] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||||
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
|
|||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction(hero)}
|
onClick={() => onAction(hero)}
|
||||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
whileHover={reduced ? undefined : { y: -1 }}
|
||||||
|
key={hero.id}
|
||||||
|
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
|
||||||
|
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.48, ease }}
|
||||||
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
|
|||||||
|
|
||||||
{rest.length > 0 && (
|
{rest.length > 0 && (
|
||||||
<div className="divide-y divide-border/10">
|
<div className="divide-y divide-border/10">
|
||||||
{rest.map(path => {
|
{rest.map((path, idx) => {
|
||||||
const meta = TYPE_META[path.type]
|
const meta = TYPE_META[path.type]
|
||||||
const Icon = meta.Icon
|
const Icon = meta.Icon
|
||||||
return (
|
return (
|
||||||
<button
|
<motion.button
|
||||||
key={path.id}
|
key={path.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction(path)}
|
onClick={() => onAction(path)}
|
||||||
|
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
|
||||||
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
||||||
>
|
>
|
||||||
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
||||||
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
|
|||||||
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
||||||
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function DashboardDailyReview({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
|
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
@@ -79,7 +79,7 @@ export function DashboardOpenLoops({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-16 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
) : loops.length === 0 ? (
|
) : loops.length === 0 ? (
|
||||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -122,7 +122,7 @@ export function DashboardDailyNoteWidget({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -158,7 +158,7 @@ export function DashboardLinkSuggestions({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
) : paths.length === 0 ? (
|
) : paths.length === 0 ? (
|
||||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
|
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
|
||||||
@@ -216,7 +216,7 @@ export function DashboardBridgesWidget({
|
|||||||
title={t('homeDashboard.widgets.bridges')}
|
title={t('homeDashboard.widgets.bridges')}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-20 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
) : suggestions.length === 0 ? (
|
) : suggestions.length === 0 ? (
|
||||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
|
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useReducedMotion } from 'motion/react'
|
import { useReducedMotion } from 'motion/react'
|
||||||
import { Inbox, Send, Bell, Mail } from 'lucide-react'
|
import { Inbox, Send, Bell, Mail, Loader2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||||
|
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
||||||
import { createNote } from '@/app/actions/notes'
|
import { createNote } from '@/app/actions/notes'
|
||||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -151,7 +152,7 @@ interface MindMapData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
onNoteSelect: (noteId: string, notebookId: string | null) => void
|
onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GmailStatus {
|
interface GmailStatus {
|
||||||
@@ -216,6 +217,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
recentNotes: BriefingNote[]
|
recentNotes: BriefingNote[]
|
||||||
inboxCount: number
|
inboxCount: number
|
||||||
|
inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||||
dueFlashcards: number
|
dueFlashcards: number
|
||||||
upcomingReminders: BriefingReminder[]
|
upcomingReminders: BriefingReminder[]
|
||||||
insights: BriefingInsight[]
|
insights: BriefingInsight[]
|
||||||
@@ -246,13 +248,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
const [echoRefreshing, setEchoRefreshing] = useState(false)
|
const [echoRefreshing, setEchoRefreshing] = useState(false)
|
||||||
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
|
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
|
||||||
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
|
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
|
||||||
|
const [briefingError, setBriefingError] = useState(false)
|
||||||
|
|
||||||
const loadBriefing = useCallback(async () => {
|
const loadBriefing = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
||||||
if (res.ok) setData(await res.json())
|
if (res.ok) {
|
||||||
|
setData(await res.json())
|
||||||
|
setBriefingError(false)
|
||||||
|
} else {
|
||||||
|
setBriefingError(true)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* état dégradé */
|
setBriefingError(true)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -292,6 +300,36 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/** Recharge briefing puis enrichit les pistes (évite loadPaths() sans argument). */
|
||||||
|
const reloadBriefingAndPaths = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
||||||
|
if (!res.ok) {
|
||||||
|
setBriefingError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const briefing = await res.json()
|
||||||
|
setData(briefing)
|
||||||
|
setBriefingError(false)
|
||||||
|
setPaths(buildFastPathsFromBriefing({
|
||||||
|
recentNotes: (briefing.recentNotes ?? []).map((n: BriefingNote) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
content: n.content,
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
})),
|
||||||
|
inboxCount: briefing.inboxCount ?? 0,
|
||||||
|
dueFlashcards: briefing.dueFlashcards ?? 0,
|
||||||
|
insights: briefing.insights ?? [],
|
||||||
|
bridgeSuggestions: briefing.bridgeSuggestions ?? [],
|
||||||
|
agentSuggestions: briefing.agentSuggestions ?? [],
|
||||||
|
}))
|
||||||
|
await loadPaths(briefing)
|
||||||
|
} catch {
|
||||||
|
setBriefingError(true)
|
||||||
|
}
|
||||||
|
}, [loadPaths])
|
||||||
|
|
||||||
const loadSentiment = useCallback(async () => {
|
const loadSentiment = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
|
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
|
||||||
@@ -454,9 +492,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
} : prev)
|
} : prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => {
|
const handleOpenFromInsight = useCallback(async (
|
||||||
|
insight: BriefingInsight,
|
||||||
|
noteId: string,
|
||||||
|
peekNoteId?: string | null,
|
||||||
|
) => {
|
||||||
await markInsightViewed(insight.id)
|
await markInsightViewed(insight.id)
|
||||||
onNoteSelect(noteId, null)
|
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
|
||||||
|
onNoteSelect(noteId, null, peek)
|
||||||
}, [markInsightViewed, onNoteSelect])
|
}, [markInsightViewed, onNoteSelect])
|
||||||
|
|
||||||
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
|
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
|
||||||
@@ -483,10 +526,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/echo')
|
const res = await fetch('/api/ai/echo')
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return }
|
if (res.status === 403 && json.code === 'ai_consent_required') {
|
||||||
|
redirectToAiConsentSettings(router)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(json.error)
|
if (!res.ok) throw new Error(json.error)
|
||||||
if (json.insight) {
|
if (json.insight) {
|
||||||
await Promise.all([loadBriefing(), loadPaths()])
|
await reloadBriefingAndPaths()
|
||||||
emitAiUsageChanged()
|
emitAiUsageChanged()
|
||||||
toast.success(t('homeDashboard.echoFound'))
|
toast.success(t('homeDashboard.echoFound'))
|
||||||
} else {
|
} else {
|
||||||
@@ -497,12 +543,12 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setEchoRefreshing(false)
|
setEchoRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [requestAiConsent, loadBriefing, loadPaths, t])
|
}, [requestAiConsent, reloadBriefingAndPaths, t, router])
|
||||||
|
|
||||||
const handleEnableAi = useCallback(async () => {
|
const handleEnableAi = useCallback(async () => {
|
||||||
await requestAiConsent()
|
await requestAiConsent()
|
||||||
await Promise.all([loadBriefing(), loadPaths()])
|
await reloadBriefingAndPaths()
|
||||||
}, [requestAiConsent, loadBriefing, loadPaths])
|
}, [requestAiConsent, reloadBriefingAndPaths])
|
||||||
|
|
||||||
const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => {
|
const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => {
|
||||||
const key = `${s.clusterAId}-${s.clusterBId}`
|
const key = `${s.clusterAId}-${s.clusterBId}`
|
||||||
@@ -596,18 +642,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
||||||
break
|
break
|
||||||
case 'compare':
|
case 'compare':
|
||||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||||
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
|
|
||||||
break
|
break
|
||||||
case 'addLink':
|
case 'addLink':
|
||||||
if (path.noteId) {
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||||
onNoteSelect(path.noteId, path.notebookId ?? null)
|
|
||||||
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'openInsight': {
|
case 'openInsight': {
|
||||||
const insight = insights.find(i => i.id === path.insightId)
|
const insight = insights.find(i => i.id === path.insightId)
|
||||||
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId)
|
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'createBridge': {
|
case 'createBridge': {
|
||||||
@@ -672,7 +714,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
{
|
{
|
||||||
key: 'connection',
|
key: 'connection',
|
||||||
label: t('homeDashboard.dailyReviewConnection'),
|
label: t('homeDashboard.dailyReviewConnection'),
|
||||||
done: pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
|
// Coché quand il n'y a plus de piste de connexion à traiter
|
||||||
|
done: !pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface')
|
const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface')
|
||||||
if (p) handlePathAction(p)
|
if (p) handlePathAction(p)
|
||||||
@@ -731,8 +774,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
onClick={handleCapture}
|
onClick={handleCapture}
|
||||||
disabled={!captureText.trim() || capturing}
|
disabled={!captureText.trim() || capturing}
|
||||||
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
|
className="absolute bottom-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"
|
||||||
|
aria-busy={capturing}
|
||||||
>
|
>
|
||||||
<Send size={12} />
|
{capturing
|
||||||
|
? <Loader2 size={12} className="animate-spin" />
|
||||||
|
: <Send size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
)
|
)
|
||||||
@@ -853,8 +899,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
/>
|
/>
|
||||||
{briefingLoading ? (
|
{briefingLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
) : reminders.length === 0 ? (
|
) : reminders.length === 0 ? (
|
||||||
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
|
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
|
||||||
@@ -911,6 +957,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
||||||
loading={mindMapLoading}
|
loading={mindMapLoading}
|
||||||
onOpenInsights={() => router.push('/insights')}
|
onOpenInsights={() => router.push('/insights')}
|
||||||
|
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
|
||||||
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
||||||
prefersReducedMotion={!!prefersReducedMotion}
|
prefersReducedMotion={!!prefersReducedMotion}
|
||||||
/>,
|
/>,
|
||||||
@@ -942,8 +989,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
return wrap(
|
return wrap(
|
||||||
<DashboardInboxWidget
|
<DashboardInboxWidget
|
||||||
count={inboxCount}
|
count={inboxCount}
|
||||||
|
notes={data?.inboxPreview ?? []}
|
||||||
loading={briefingLoading}
|
loading={briefingLoading}
|
||||||
onOpen={() => router.push('/home?forceList=1')}
|
onOpen={() => router.push('/home?forceList=1')}
|
||||||
|
onSelect={onNoteSelect}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
case 'revision':
|
case 'revision':
|
||||||
@@ -1021,6 +1070,20 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
|
||||||
|
|
||||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
|
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
|
||||||
|
{briefingError && !data && (
|
||||||
|
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/5 px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<p className="text-sm text-ink dark:text-dark-ink">
|
||||||
|
{t('homeDashboard.briefingLoadError')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void reloadBriefingAndPaths() }}
|
||||||
|
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-ink text-white dark:bg-white dark:text-black"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.briefingRetry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* ── En-tête : orientation en 2 secondes ── */}
|
{/* ── En-tête : orientation en 2 secondes ── */}
|
||||||
<header className="mb-5">
|
<header className="mb-5">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">
|
||||||
@@ -1059,7 +1122,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="pb-24">
|
<div className="pb-24">
|
||||||
<DashboardWidgetGrid renderWidget={renderWidget} />
|
<DashboardWidgetGrid
|
||||||
|
renderWidget={renderWidget}
|
||||||
|
isWidgetEmpty={(id) => {
|
||||||
|
if (briefingLoading) return false
|
||||||
|
if (id === 'sentiment') return !sentimentLoading && (!sentiment?.available || !sentiment?.dominantEmotion)
|
||||||
|
if (id === 'reminders') return reminders.length === 0
|
||||||
|
if (id === 'revision') return dueFlashcards === 0
|
||||||
|
if (id === 'inbox') return inboxCount === 0
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
interface DashboardWidgetGridProps {
|
interface DashboardWidgetGridProps {
|
||||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||||
|
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableWidget({
|
function SortableWidget({
|
||||||
@@ -139,7 +140,7 @@ function ZoneColumn({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
@@ -228,8 +229,11 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
|||||||
}, [persistLayout, t])
|
}, [persistLayout, t])
|
||||||
|
|
||||||
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const hidden = hiddenWidgetIds(layout)
|
const hidden = hiddenWidgetIds(layout)
|
||||||
const catalog = catalogByCategory(layout)
|
const catalog = catalogByCategory(layout)
|
||||||
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ export function FlashcardGenerateDialog({
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (data.errorKey) {
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.generateFailed')))
|
||||||
toast.error(t(data.errorKey) || data.error)
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || t('flashcards.generateFailed'))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCards(data.cards || [])
|
setCards(data.cards || [])
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function RevisionView() {
|
|||||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setActiveDeckId(deckId)
|
setActiveDeckId(deckId)
|
||||||
@@ -432,7 +432,7 @@ export function RevisionView() {
|
|||||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||||
setExpandedDeckId(null)
|
setExpandedDeckId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function FusionModal({
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.error || 'Failed to generate fusion')
|
throw new Error(data.errorKey ? t(data.errorKey) : (data.error || 'Failed to generate fusion'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.fusedNote) {
|
if (!data.fusedNote) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
|
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
|
||||||
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
@@ -67,6 +67,10 @@ const NotebookSiteDialog = dynamic(
|
|||||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
)
|
||||||
|
const NotebookSlidesDialog = dynamic(
|
||||||
|
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
const StructuredViewsIntro = dynamic(
|
const StructuredViewsIntro = dynamic(
|
||||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -151,6 +155,7 @@ export function HomeClient({
|
|||||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||||
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
||||||
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
||||||
|
const [showNotebookSlides, setShowNotebookSlides] = useState(false)
|
||||||
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
||||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||||
@@ -179,7 +184,7 @@ export function HomeClient({
|
|||||||
toast.success(`${data.created} notes importées !`)
|
toast.success(`${data.created} notes importées !`)
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Erreur')
|
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
@@ -652,7 +657,10 @@ export function HomeClient({
|
|||||||
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openNoteId = searchParams.get('openNote')
|
const openNoteId = searchParams.get('openNote')
|
||||||
if (!openNoteId) return
|
if (!openNoteId) {
|
||||||
|
setEditingNote(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@@ -811,8 +819,15 @@ export function HomeClient({
|
|||||||
|
|
||||||
const handleEditorClose = useCallback(() => {
|
const handleEditorClose = useCallback(() => {
|
||||||
setEditingNote(null)
|
setEditingNote(null)
|
||||||
|
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
|
||||||
|
if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) {
|
||||||
|
router.replace('/home', { scroll: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.delete('openNote')
|
params.delete('openNote')
|
||||||
|
params.delete('peekNote')
|
||||||
|
params.delete('from')
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||||
}, [router, searchParams])
|
}, [router, searchParams])
|
||||||
@@ -826,10 +841,17 @@ export function HomeClient({
|
|||||||
// Show dashboard when no active filter/view params
|
// Show dashboard when no active filter/view params
|
||||||
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
|
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
|
||||||
|
|
||||||
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
|
const handleDashboardNoteSelect = useCallback((
|
||||||
|
noteId: string,
|
||||||
|
notebookId: string | null,
|
||||||
|
peekNoteId?: string | null,
|
||||||
|
) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('openNote', noteId)
|
params.set('openNote', noteId)
|
||||||
if (notebookId) params.set('notebook', notebookId)
|
params.set('from', 'dashboard')
|
||||||
|
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
|
||||||
|
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
|
||||||
|
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
|
||||||
router.push(`/home?${params.toString()}`)
|
router.push(`/home?${params.toString()}`)
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
@@ -1136,6 +1158,11 @@ export function HomeClient({
|
|||||||
label: t('notebookSite.shortTitle') || 'Site web',
|
label: t('notebookSite.shortTitle') || 'Site web',
|
||||||
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <Presentation size={14} />,
|
||||||
|
label: t('notebook.slides') || 'Présentation',
|
||||||
|
action: () => { setShowNotebookSlides(true); setAiMenuOpen(false) },
|
||||||
|
},
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -1452,6 +1479,14 @@ export function HomeClient({
|
|||||||
onClose={() => setShowNotebookSite(false)}
|
onClose={() => setShowNotebookSite(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNotebookSlides && currentNotebook && (
|
||||||
|
<NotebookSlidesDialog
|
||||||
|
notebookId={currentNotebook.id}
|
||||||
|
notebookName={currentNotebook.name}
|
||||||
|
onClose={() => setShowNotebookSlides(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export interface IntelligenceHubProps {
|
|||||||
onDismissInsight: (insight: IntelBriefingInsight) => void
|
onDismissInsight: (insight: IntelBriefingInsight) => void
|
||||||
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||||
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||||
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
|
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string, peekNoteId?: string | null) => void
|
||||||
dismissingInsightId: string | null
|
dismissingInsightId: string | null
|
||||||
actingBridgeSuggestionKey: string | null
|
actingBridgeSuggestionKey: string | null
|
||||||
prefersReducedMotion: boolean
|
prefersReducedMotion: boolean
|
||||||
@@ -334,7 +334,7 @@ export function IntelligenceHub({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
|
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
|
||||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
className="inline-flex items-center gap-1 text-[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"
|
||||||
>
|
>
|
||||||
<GitCompare size={9} />
|
<GitCompare size={9} />
|
||||||
|
|||||||
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,6 +9,7 @@ import Link from 'next/link'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||||
|
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
||||||
@@ -68,11 +69,30 @@ export function LandingPage() {
|
|||||||
return () => { root.style.overflow = prev }
|
return () => { root.style.overflow = prev }
|
||||||
}, [menuOpen])
|
}, [menuOpen])
|
||||||
|
|
||||||
|
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||||
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
{
|
||||||
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
key: 'pro',
|
||||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
popular: true,
|
||||||
|
hasTrial: true,
|
||||||
|
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
|
||||||
|
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'business',
|
||||||
|
popular: false,
|
||||||
|
hasTrial: true,
|
||||||
|
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
|
||||||
|
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enterprise',
|
||||||
|
popular: false,
|
||||||
|
hasTrial: false,
|
||||||
|
price: t('landing.pricing.enterprisePrice'),
|
||||||
|
period: '',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -467,7 +487,9 @@ export function LandingPage() {
|
|||||||
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/45'}`}
|
||||||
>
|
>
|
||||||
{t('landing.pricing.annual')}
|
{t('landing.pricing.annual')}
|
||||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
|
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||||
|
{t('landing.pricing.savePercent')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,8 +515,19 @@ export function LandingPage() {
|
|||||||
<span className="text-3xl font-serif">{plan.price}</span>
|
<span className="text-3xl font-serif">{plan.price}</span>
|
||||||
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{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/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||||
<ul className="space-y-2.5 mb-8 flex-1">
|
<ul className="space-y-2.5 mb-8 flex-1">
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||||
|
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||||
|
{t('landing.pricing.trialFeature', { days: trialDays })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||||
if (!feat || feat.startsWith('landing.')) return null
|
if (!feat || feat.startsWith('landing.')) return null
|
||||||
@@ -514,7 +547,9 @@ export function LandingPage() {
|
|||||||
: 'bg-white/10 text-white hover:bg-white/15'
|
: 'bg-white/10 text-white hover:bg-white/15'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`landing.pricing.${plan.key}.cta`)}
|
{plan.hasTrial
|
||||||
|
? t('landing.pricing.trialCta', { days: trialDays })
|
||||||
|
: t(`landing.pricing.${plan.key}.cta`)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -568,7 +603,11 @@ export function LandingPage() {
|
|||||||
if (!label || label.startsWith('landing.')) return null
|
if (!label || label.startsWith('landing.')) return null
|
||||||
return (
|
return (
|
||||||
<li key={j}>
|
<li key={j}>
|
||||||
<a href={href} className="hover:text-white transition-colors">{label}</a>
|
{href.startsWith('/') ? (
|
||||||
|
<Link href={href} className="hover:text-white transition-colors">{label}</Link>
|
||||||
|
) : (
|
||||||
|
<a href={href} className="hover:text-white transition-colors">{label}</a>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
|
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { getLocalStorageAiConsent, setLocalStorageAiConsent, removeLocalStorageAiConsent } from '@/lib/consent/ai-consent-client'
|
import { getLocalStorageAiConsent, setLocalStorageAiConsent, removeLocalStorageAiConsent } from '@/lib/consent/ai-consent-client'
|
||||||
|
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { AiConsentModal } from './ai-consent-modal'
|
import { AiConsentModal } from './ai-consent-modal'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -24,6 +26,7 @@ interface AiConsentProviderProps {
|
|||||||
export function AiConsentProvider({ children, initialPersistentConsent = false }: AiConsentProviderProps) {
|
export function AiConsentProvider({ children, initialPersistentConsent = false }: AiConsentProviderProps) {
|
||||||
const { data: session, update: updateSession } = useSession()
|
const { data: session, update: updateSession } = useSession()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const [persistentConsent, setPersistentConsent] = useState(false)
|
const [persistentConsent, setPersistentConsent] = useState(false)
|
||||||
const [sessionConsent, setSessionConsent] = useState(false)
|
const [sessionConsent, setSessionConsent] = useState(false)
|
||||||
@@ -60,11 +63,22 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
|
|||||||
return Promise.resolve(true)
|
return Promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalOpen(true)
|
// On settings page we keep the inline modal; elsewhere redirect to settings
|
||||||
|
// so the user sees exactly where to grant consent.
|
||||||
|
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/settings/general')) {
|
||||||
|
setModalOpen(true)
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
pendingResolveRef.current = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectToAiConsentSettings(router, { replace: false })
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
pendingResolveRef.current = resolve
|
pendingResolveRef.current = resolve
|
||||||
|
pendingResolveRef.current(false)
|
||||||
|
pendingResolveRef.current = null
|
||||||
})
|
})
|
||||||
}, [hasAiConsent])
|
}, [hasAiConsent, router])
|
||||||
|
|
||||||
const handleConfirm = async (remember: boolean) => {
|
const handleConfirm = async (remember: boolean) => {
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState, useRef, useState, Suspense } from 'react';
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
import { authenticate } from '@/app/actions/auth';
|
import { authenticate } from '@/app/actions/auth';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
||||||
|
import { resendSignupVerification } from '@/app/actions/auth-verify';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
function AuthDivider({ label }: { label: string }) {
|
function AuthDivider({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +44,7 @@ function LoginButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({
|
function LoginFormInner({
|
||||||
allowRegister = true,
|
allowRegister = true,
|
||||||
googleAuthEnabled = false,
|
googleAuthEnabled = false,
|
||||||
authError,
|
authError,
|
||||||
@@ -51,7 +54,11 @@ export function LoginForm({
|
|||||||
authError?: string;
|
authError?: string;
|
||||||
}) {
|
}) {
|
||||||
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||||
const { t } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const verified = searchParams.get('verified') === '1';
|
||||||
const oauthError =
|
const oauthError =
|
||||||
authError === 'SessionRequired'
|
authError === 'SessionRequired'
|
||||||
? t('auth.sessionExpired')
|
? t('auth.sessionExpired')
|
||||||
@@ -59,6 +66,21 @@ export function LoginForm({
|
|||||||
? t('auth.oauthAccountNotLinked')
|
? t('auth.oauthAccountNotLinked')
|
||||||
: authError ?? null;
|
: authError ?? null;
|
||||||
|
|
||||||
|
const showUnverified = errorMessage === 'EMAIL_NOT_VERIFIED';
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
const email = emailRef.current?.value?.trim() ?? '';
|
||||||
|
if (!email) {
|
||||||
|
toast.error(t('auth.verifyMissingEmail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResending(true);
|
||||||
|
const result = await resendSignupVerification(email, language);
|
||||||
|
setResending(false);
|
||||||
|
if (result.success) toast.success(t('auth.verifyResent'));
|
||||||
|
else toast.error(t('auth.verifyResendFailed'));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -71,6 +93,12 @@ export function LoginForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{verified && (
|
||||||
|
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
|
||||||
|
{t('auth.emailVerifiedBanner')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{oauthError && (
|
{oauthError && (
|
||||||
<p className="text-sm text-red-500 text-center px-2" role="alert">
|
<p className="text-sm text-red-500 text-center px-2" role="alert">
|
||||||
{oauthError}
|
{oauthError}
|
||||||
@@ -94,6 +122,7 @@ export function LoginForm({
|
|||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref={emailRef}
|
||||||
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -136,13 +165,26 @@ export function LoginForm({
|
|||||||
|
|
||||||
<LoginButton />
|
<LoginButton />
|
||||||
|
|
||||||
<div
|
<div className="space-y-2" aria-live="polite" aria-atomic="true">
|
||||||
className="flex h-8 items-end space-x-1"
|
{showUnverified && (
|
||||||
aria-live="polite"
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
|
||||||
aria-atomic="true"
|
<p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
|
||||||
>
|
<button
|
||||||
{errorMessage && (
|
type="button"
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
onClick={handleResend}
|
||||||
|
disabled={resending}
|
||||||
|
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resending ? t('auth.sending') : t('auth.resendVerification')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorMessage && !showUnverified && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{errorMessage === 'Invalid credentials.'
|
||||||
|
? t('auth.invalidCredentials')
|
||||||
|
: errorMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -164,3 +206,15 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoginForm(props: {
|
||||||
|
allowRegister?: boolean;
|
||||||
|
googleAuthEnabled?: boolean;
|
||||||
|
authError?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||||
|
<LoginFormInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.titleGenerationError')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -502,7 +502,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || t('ai.reformulationError'))
|
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.reformulationError')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -542,7 +542,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.clarifyFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -575,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.shortenFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -608,7 +608,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.improveFailed')))
|
||||||
setContentImmediate(data.reformulatedText || data.text)
|
setContentImmediate(data.reformulatedText || data.text)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -642,7 +642,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
body: JSON.stringify({ text: content })
|
body: JSON.stringify({ text: content })
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.transformFailed')))
|
||||||
|
|
||||||
setContentImmediate(data.transformedText)
|
setContentImmediate(data.transformedText)
|
||||||
setIsMarkdown(true)
|
setIsMarkdown(true)
|
||||||
|
|||||||
@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
|
|||||||
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const peekNoteId = searchParams.get('peekNote')
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const isRtl = language === 'fa' || language === 'ar'
|
const isRtl = language === 'fa' || language === 'ar'
|
||||||
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
||||||
|
|
||||||
|
const stripPeekFromUrl = useCallback(() => {
|
||||||
|
if (!searchParams.get('peekNote')) return
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('peekNote')
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||||
|
}, [router, searchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpenPeek = (event: Event) => {
|
const onOpenPeek = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
||||||
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
}
|
}
|
||||||
}, [noteId, t])
|
}, [noteId, t])
|
||||||
|
|
||||||
|
// Dashboard « Comparer / Lier » : ouvrir la note liée à droite dès que l’éditeur est monté.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!peekNoteId || peekNoteId === noteId) return
|
||||||
|
let cancelled = false
|
||||||
|
void getNoteById(peekNoteId).then((fetched) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (fetched) {
|
||||||
|
setPeekState(prev => (prev?.note.id === fetched.id ? prev : { note: fetched }))
|
||||||
|
} else {
|
||||||
|
toast.error(t('notePeek.loadFailed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [peekNoteId, noteId, t])
|
||||||
|
|
||||||
const handleClosePeek = useCallback(() => {
|
const handleClosePeek = useCallback(() => {
|
||||||
setPeekState(null)
|
setPeekState(null)
|
||||||
}, [])
|
stripPeekFromUrl()
|
||||||
|
}, [stripPeekFromUrl])
|
||||||
|
|
||||||
const handleOpenPeekFully = useCallback(() => {
|
const handleOpenPeekFully = useCallback(() => {
|
||||||
if (!peekState) return
|
if (!peekState) return
|
||||||
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
}))
|
}))
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.set('openNote', peekState.note.id)
|
params.set('openNote', peekState.note.id)
|
||||||
|
params.delete('peekNote')
|
||||||
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
||||||
setPeekState(null)
|
setPeekState(null)
|
||||||
}, [noteId, peekState, router, searchParams])
|
}, [noteId, peekState, router, searchParams])
|
||||||
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
blockId={peekState.blockId}
|
blockId={peekState.blockId}
|
||||||
onClose={handleClosePeek}
|
onClose={handleClosePeek}
|
||||||
onOpenFully={handleOpenPeekFully}
|
onOpenFully={handleOpenPeekFully}
|
||||||
|
onBackToDashboard={
|
||||||
|
searchParams.get('from') === 'dashboard'
|
||||||
|
? () => router.replace('/home')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user