9 Commits

Author SHA1 Message Date
Antigravity
a77f3ffb6e fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m15s
CI / Deploy production (on server) (push) Has been skipped
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques,
prompts wizard/slides moins génériques, cron agents rattrapage nextRun null,
slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard,
packs Stripe marqués non configurés sans price ID.
2026-07-16 20:48:55 +00:00
Antigravity
556a0b2f3f feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
2026-07-16 20:43:18 +00:00
Antigravity
704fed1191 fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue
- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
2026-07-16 17:25:57 +00:00
Antigravity
45297da333 fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
2026-07-16 16:58:07 +00:00
Antigravity
4fe31ebc99 fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:42:25 +00:00
Antigravity
30da592ba2 feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00
Antigravity
d38a99586b feat(sidebar): redimensionnement largeur et zone carnets, retrait note du jour
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m5s
CI / Deploy production (on server) (push) Successful in 23s
Ajoute une poignée drag pour la largeur de la sidebar (persistée) et sépare inbox/arbre carnets avec slider vertical ; supprime le bouton Note du jour devenu inutile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 11:35:03 +00:00
Antigravity
6e13b6d207 fix: note supprimée depuis éditeur — disparaît de la liste sans refresh
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m25s
CI / Deploy production (on server) (push) Successful in 1m1s
Root cause: NOTE_CHANGE_EVENT listener dans home-client ne gérait que 'updated'
Quand deleteNote émettait { type: 'deleted' }, l'événement était ignoré.

Fix: listener gère maintenant 'deleted' (removeNoteFromList) + 'created'
2026-07-05 18:54:41 +00:00
Antigravity
4d95234e32 ci: trigger deploy 2026-07-05 18:46:13 +00:00
199 changed files with 48847 additions and 11920 deletions

View File

@@ -78,3 +78,6 @@ TELEGRAM_CHAT_ID=<var>
# Monitoring
METRICS_TOKEN=<secret>
GRAFANA_ADMIN_PASSWORD=<secret>
# Monitoring LAN : définir GRAFANA_BIND et GRAFANA_URL dans .env.docker (voir deploy)
# GRAFANA_BIND=127.0.0.1
# GRAFANA_URL=http://localhost:3002

View File

@@ -174,6 +174,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -232,6 +234,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
# Write metrics token file for Prometheus (same secret)
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true

View File

@@ -63,6 +63,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -121,6 +123,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"

View File

@@ -2,18 +2,18 @@
## Learned User Preferences
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
- 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.
- 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.
- 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.
- 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 responsive** (`components/sidebar.tsx`) — `w-80` (320px) de base, plus large en `xl`/`2xl` (grands écrans) ; garder le fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur ces largeurs (rail fixe 54px) ; `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 quune page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- 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+`, 280560px, 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 quune 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.
- 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.
- 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. **CI/CD Gitea spécifique** : (1) `actions/upload-artifact` et `download-artifact` doivent utiliser **@v3** (pas @v4 — non supporté par Gitea, erreur `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 — 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).
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **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.
- 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 »).
- **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`.
- 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).
- 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.
- 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).
## Learned Workspace Facts
@@ -23,9 +23,9 @@
- 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`.
- **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`.
- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; 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`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
- **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`.
- 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**.
- 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, reformulation adaptée au contenu, KaTeX pour équations, images incluses) — quota IA consommé uniquement sur publication IA ; **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.
- 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.

View File

@@ -150,7 +150,7 @@ services:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget --header \"x-api-key: $${MCP_API_KEY:-dev-key}\" -q -O /dev/null http://localhost:3001/ || exit 1"]
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -28,6 +28,28 @@ function setCachedKey(keyHash, data) {
keyCache.set(keyHash, { data, timestamp: Date.now() });
}
async function getEffectiveTier(prisma, userId) {
if (!userId) return 'BASIC';
const sub = await prisma.subscription.findUnique({
where: { userId },
select: { tier: true, status: true, currentPeriodEnd: true },
});
if (!sub) return 'BASIC';
if (sub.status === 'ACTIVE' || sub.status === 'TRIALING') return sub.tier;
if (
(sub.status === 'PAST_DUE' || sub.status === 'CANCELED') &&
sub.currentPeriodEnd &&
new Date() < new Date(sub.currentPeriodEnd)
) {
return sub.tier;
}
return 'BASIC';
}
function isMcpTierAllowed(tier) {
return tier !== 'BASIC';
}
/**
* Generate a new API key.
* @param {import('@prisma/client').PrismaClient} prisma
@@ -111,6 +133,9 @@ export async function validateApiKey(prisma, rawKey) {
const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null;
const tier = await getEffectiveTier(prisma, info.userId);
if (!isMcpTierAllowed(tier)) return null;
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: configKey },

View File

@@ -1,24 +1,16 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
type EntitlementRow = {
tier: string
feature: string
limitValue: number | null
mode: 'limited' | 'unlimited' | 'unavailable'
}
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
type BillingAdminData = {
entitlements: EntitlementRow[]
billingConfig: Record<string, string>
usageOverview: {
period: string
@@ -26,34 +18,55 @@ type BillingAdminData = {
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
features: string[]
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
unlimited: boolean
}>
creditCosts?: Record<string, number>
creditPacks?: Array<{
id: string
credits: number
defaultDisplay: string
}>
}
function getEntitlement(
entitlements: EntitlementRow[],
tier: string,
feature: string,
): EntitlementRow | undefined {
return entitlements.find((e) => e.tier === tier && e.feature === feature)
const SUBSCRIPTION_PRICE_KEYS = [
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [savingCell, setSavingCell] = useState<string | null>(null)
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
try {
const data: Record<string, string> = {
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
}
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
data[key] = String(formData.get(key) ?? '')
}
await updateBillingConfig(data)
toast.success(t('admin.billing.configSaved'))
@@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const handleEntitlementChange = async (
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) => {
const key = `${activeTier}:${feature}`
setSavingCell(key)
try {
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
toast.success(t('admin.billing.limitSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
} finally {
setSavingCell(null)
}
}
return (
<div className="space-y-8">
<div>
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
<div className="flex items-center space-x-2">
<Checkbox
id="BILLING_ENABLED"
@@ -107,81 +103,105 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<div className="border-t border-border/50 pt-4">
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
<div className="grid gap-4 sm:grid-cols-3">
{PACK_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Settings2 className="h-5 w-5" />
<Coins className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{initialData.tiers.map((tier) => (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
activeTier === tier
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(initialData.creditAllocations ?? []).map((row) => (
<div
key={row.tier}
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
>
{tier}
</button>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{row.tier}
</p>
<p className="text-2xl font-semibold tabular-nums">
{row.unlimited
? t('admin.billing.modeUnlimited')
: row.monthlyCredits?.toLocaleString('fr-FR')}
</p>
{!row.unlimited && (
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.creditsPerMonth')}
</p>
)}
</div>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
<th className="py-2">{t('admin.billing.actions')}</th>
</tr>
</thead>
<tbody>
{initialData.features.map((feature) => {
const row = getEntitlement(initialData.entitlements, activeTier, feature)
const mode = row?.mode ?? 'unavailable'
const cellKey = `${activeTier}:${feature}`
return (
<EntitlementRowEditor
key={`${activeTier}:${feature}`}
feature={feature}
mode={mode}
limitValue={row?.limitValue ?? null}
isSaving={savingCell === cellKey}
onSave={handleEntitlementChange}
t={t}
/>
)
})}
</tbody>
</table>
</div>
{(initialData.creditPacks?.length ?? 0) > 0 && (
<div className="border-t border-border/40 pt-3">
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
<div className="flex flex-wrap gap-2">
{(initialData.creditPacks ?? []).map((pack) => (
<span
key={pack.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
>
<span className="font-semibold uppercase">{pack.id}</span>
<span className="text-muted-foreground">
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
</span>
</span>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
{t('admin.billing.creditsCostsNote')}
</p>
<p className="text-xs text-muted-foreground">
{t('admin.billing.creditsResetHint')}
</p>
</div>
</div>
@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}
</p>
</div>
@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</div>
)
}
function EntitlementRowEditor({
feature,
mode: initialMode,
limitValue: initialLimit,
isSaving,
onSave,
t,
}: {
feature: string
mode: 'unavailable' | 'unlimited' | 'limited'
limitValue: number | null
isSaving: boolean
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
t: (key: string, params?: Record<string, string | number>) => string
}) {
const [mode, setMode] = useState(initialMode)
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
useEffect(() => {
setMode(initialMode)
setLimit(initialLimit != null ? String(initialLimit) : '50')
}, [initialMode, initialLimit])
return (
<tr className="border-b border-border/30">
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
<td className="py-3 pr-4">
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
<option value="limited">{t('admin.billing.modeLimited')}</option>
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
</select>
</td>
<td className="py-3 pr-4">
{mode === 'limited' ? (
<Input
type="number"
min={0}
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="h-9 w-24 text-xs"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving}
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
>
{isSaving ? '…' : t('admin.billing.saveLimit')}
</Button>
</td>
</tr>
)
}

View File

@@ -1,58 +1,184 @@
import { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
import prisma from '@/lib/prisma'
import Link from 'next/link'
async function getAdminDashboardStats() {
const now = new Date()
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const [
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
recentUsers,
] = await Promise.all([
prisma.user.count().catch(() => 0),
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
prisma.notebook.count().catch(() => 0),
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
prisma.subscription.count({
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
}).catch(() => 0),
prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 8,
select: { id: true, name: true, email: true, createdAt: true, role: true },
}).catch(() => []),
])
// AI usage this month
let aiRequestsMonth = 0
try {
aiRequestsMonth = await prisma.usageLog.aggregate({
where: { periodStart: { gte: monthStart } },
_sum: { requestsCount: true },
}).then((r) => r._sum.requestsCount ?? 0)
} catch {
try {
aiRequestsMonth = await prisma.agentAction.count({
where: { createdAt: { gte: monthStart } },
})
} catch {
aiRequestsMonth = 0
}
}
return {
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
aiRequestsMonth,
recentUsers,
}
}
export default async function AdminPage() {
const users = await getUsers()
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
// Mock metrics data - in a real app, these would come from analytics
const metrics = [
{
title: 'Total Users',
value: users.length,
trend: { value: 12, isPositive: true },
title: 'Utilisateurs',
value: stats.userCount,
trend: undefined as { value: number; isPositive: boolean } | undefined,
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
title: 'Active Sessions',
value: '24',
trend: { value: 8, isPositive: true },
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Total Notes',
value: '1,234',
trend: { value: 24, isPositive: true },
title: 'Notes',
value: stats.noteCount.toLocaleString('fr-FR'),
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
{
title: 'AI Requests',
value: '856',
trend: { value: 5, isPositive: false },
title: 'Abonnements payants',
value: stats.paidSubs,
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
},
{
title: 'Agents actifs',
value: stats.agentsEnabled,
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Exécutions agents (24h)',
value: stats.agentRunsToday,
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Usage IA (mois)',
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-foreground">
Administration
</h1>
<p className="text-muted-foreground mt-1">
Vue d&apos;ensemble en temps réel {stats.notebookCount} carnets · {users.length} comptes listés
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/admin/billing"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Facturation & quotas
</Link>
<Link
href="/admin/ai"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Config IA
</Link>
<Link
href="/admin/users"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Utilisateurs
</Link>
<Link
href="/admin/settings"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Paramètres
</Link>
</div>
</div>
<AdminMetrics metrics={metrics} />
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
</p>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Derniers utilisateurs
</h2>
{stats.recentUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
) : (
<ul className="divide-y divide-border">
{stats.recentUsers.map((u) => (
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<div className="text-right shrink-0">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
<p className="text-[10px] text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Raccourcis ops
</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
</ul>
</div>
</div>
</div>
)

View File

@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
}
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
)
// Selected Models State
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
tags: [],
embeddings: [],
chat: [],
slides: [],
})
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
tags: false,
embeddings: false,
chat: false,
slides: false,
})
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
}
if (slidesProvider === 'ollama') {
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (slidesProvider === 'lmstudio') {
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[slidesProvider]
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
if (url && key) await fetchModels('slides', slidesProvider, url, key)
}
}
fetchInitial()
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}
}
// Slides provider (dedicated model for deck generation — never auto-renamed)
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
if (slidesProv) {
if (slidesProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (slidesProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_slides') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
}
const result = await updateSystemConfig(data)
setIsSaving(false)
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
</div>
</div>
<div className="p-6 space-y-6">
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
/>
</div>
</div>
{/* Slides Provider — dedicated model for deck generation */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span>📑</span> {t('admin.ai.slidesProvider')}
</h3>
<p className="text-xs text-muted-foreground">
{t('admin.ai.slidesDescription')}
</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_SLIDES"
name="AI_PROVIDER_SLIDES"
value={slidesProvider}
onChange={(e) => {
setSlidesProvider(e.target.value as AIProvider)
setSelectedSlidesModel('')
setDynamicModels((prev) => ({ ...prev, slides: [] }))
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map((opt) => (
<option key={`slides-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
{t('admin.ai.slidesQuotaNote')}
</p>
</div>
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>

View File

@@ -3,9 +3,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const { t } = useLanguage()
const [users, setUsers] = useState(initialUsers)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [resettingId, setResettingId] = useState<string | null>(null)
const handleDelete = async (id: string) => {
if (!confirm(t('admin.users.confirmDelete'))) return
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
}
}
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
const label = user.email || user.name || user.id
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
setResettingId(user.id)
try {
const result = await resetUserQuotas(user.id)
toast.success(
t('admin.users.resetQuotasSuccess', {
period: result.period,
count: result.featuresReset.length,
}),
)
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
)
} finally {
setResettingId(null)
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleResetQuotas(user)}
disabled={resettingId === user.id}
title={t('admin.users.resetQuotas')}
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
>
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -1,8 +1,35 @@
'use client';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider';
import Link from 'next/link';
import { Globe } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
function AuthHeader() {
const { t } = useLanguage();
return (
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
aria-label={t('general.back')}
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
);
}
export default function AuthLayout({
children,
@@ -15,31 +42,13 @@ export default function AuthLayout({
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
<AuthHeader />
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2025 Memento Labs
© 2026 Memento
</p>
</div>
</main>

File diff suppressed because it is too large Load Diff

View File

@@ -35,13 +35,13 @@ export default async function MainLayout({
initialTranslations={initialTranslations}
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
<div className="flex h-screen overflow-hidden bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
<Sidebar user={session?.user} />
</Suspense>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
Skip to content
</a>

View File

@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
const handleThemeChange = async (value: string) => {
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
persistThemePreference(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved'))
}

View File

@@ -1,11 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { useLanguage } from '@/lib/i18n'
export default function IntegrationsPage() {
const { t } = useLanguage()
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false)
// ── Gmail ────────────────────────────────────────────────────────────
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailRecent, setGmailRecent] = useState(0)
const [gmailScanning, setGmailScanning] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => {
fetch('/api/integrations/gmail').then((r) => r.json()),
]).then(([rw, cal, gmail]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
setGmailConnected(gmail.connected)
setGmailRecent(gmail.recentCaptures ?? 0)
}).finally(() => {
setLoading(false)
setCalLoading(false)
setGmailLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
toast.success(t('integrations.calendarConnected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('connected') === 'gmail') {
setGmailConnected(true)
toast.success(t('integrations.gmail.connected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
}, [t])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwiseconnecté')
toast.success(t('integrations.readwiseDisconnected'))
}
// ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
})
} else {
toast.error('Erreur lors de la création de la note')
toast.error(t('integrations.createNoteError'))
}
}
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendarconnecté')
toast.success(t('integrations.calendarDisconnected'))
}
// ── Gmail handlers ─────────────────────────────────────────────────────
const handleGmailConnect = () => {
window.location.href = '/api/integrations/gmail?connect=1'
}
const handleGmailScan = async () => {
setGmailScanning(true)
try {
const res = await fetch('/api/integrations/gmail?scan=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setGmailRecent(prev => prev + (data.created ?? 0))
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
} catch {
toast.error(t('integrations.gmailScanError'))
} finally {
setGmailScanning(false)
}
}
const handleGmailDisconnect = async () => {
await fetch('/api/integrations/gmail', { method: 'DELETE' })
setGmailConnected(false)
setGmailRecent(0)
toast.success(t('integrations.gmail.disconnected'))
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
<Check size={11} /> {t('integrations.connected')}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté
<X size={11} /> {t('integrations.notConnected')}
</span>
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
<p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
</div>
{/* ── Gmail ──────────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Mail size={20} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
</div>
</div>
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
</div>
{!gmailConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title={t('integrations.gmail.helpTitle')}
steps={[
{ text: t('integrations.gmail.helpStep1') },
{ text: t('integrations.gmail.helpStep2') },
{ text: t('integrations.gmail.helpStep3') },
{ text: t('integrations.gmail.helpStep4') },
]}
/>
<button
onClick={handleGmailConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<Mail size={14} />
{t('integrations.gmail.connect')}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleGmailScan}
disabled={gmailScanning}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('integrations.gmail.scanNow')}
</button>
<button
onClick={handleGmailDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
{t('integrations.gmail.disconnect')}
</button>
</div>
{gmailRecent > 0 && (
<p className="text-[11px] text-concrete">
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
</p>
)}
</div>
)}
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
<p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title="Comment fonctionne Google Calendar ?"
title={t('integrations.calendarInfo')}
steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
{ text: t('integrations.calendarHelpStep1') },
{ text: t('integrations.calendarHelpStep2') },
{ text: t('integrations.calendarHelpStep3') },
{ text: t('integrations.calendarHelpStep4') },
]}
/>
<button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
{t('integrations.connectCalendar')}
</button>
</div>
) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui
{t('integrations.todayEvents')}
</button>
<button
onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
>
+ Note
{t('integrations.addNote')}
</button>
</div>
))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
<p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
<SettingsHelpBox
title="Comment fonctionne Readwise ?"
title={t('integrations.readwiseInfo')}
steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
{ text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: t('integrations.readwiseHelpStep2') },
{ text: t('integrations.readwiseHelpStep3') },
{ text: t('integrations.readwiseHelpStep4') },
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
]}
/>
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
placeholder={t('integrations.readwiseTokenPlaceholder')}
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/>
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
{t('integrations.connect')}
</button>
</div>
</div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant
{t('integrations.syncNow')}
</button>
<button
onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
{t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
</p>
)}
</div>
{/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div>
</div>
)

View File

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

View File

@@ -0,0 +1,22 @@
'use client'
import { useLanguage } from '@/lib/i18n'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
export function McpSettingsHeader() {
const { t } = useLanguage()
return (
<SettingsHelpBox
title={t('mcpSettings.helpBox.title')}
defaultOpen={true}
steps={[
{ text: t('mcpSettings.helpBox.step1') },
{ text: t('mcpSettings.helpBox.step2') },
{ text: t('mcpSettings.helpBox.step3') },
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
]}
/>
)
}

View File

@@ -1,32 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { listMcpKeys, getMcpServerStatus, getMcpAccessStatus } from '@/app/actions/mcp-keys'
import { McpSettingsHeader } from './mcp-settings-header'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) redirect('/api/auth/signin')
const [keys, serverStatus] = await Promise.all([
const [keys, serverStatus, access] = await Promise.all([
listMcpKeys(),
getMcpServerStatus(),
getMcpAccessStatus(),
])
return (
<div className="space-y-8">
<SettingsHelpBox
title="Qu'est-ce que MCP (Model Context Protocol) ?"
defaultOpen={true}
steps={[
{ text: 'MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
{ text: 'Memento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
{ text: 'Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l\'URL du serveur.' },
{ text: 'Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.', link: { label: 'Documentation MCP', href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent.' },
]}
/>
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
<McpSettingsHeader />
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} mcpAllowed={access.allowed} tier={access.tier} />
</div>
)
}

View File

@@ -1,16 +1,12 @@
'use client'
import { motion } from 'motion/react'
/**
* Plus danimation dentrée (opacity / translate) :
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
* Conteneur neutre uniquement.
*/
export default function MainTemplate({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 flex flex-col min-h-0"
>
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
{children}
</motion.div>
</div>
)
}

View File

@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
import { loadTranslations } from '@/lib/i18n/load-translations'
import { PublicProviders } from '@/components/public-providers'
/**
* Le body racine est en hauteur décran avec débordement masqué
* (pour lapplication connectée). Les pages publiques (accueil, tarifs,
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
* défilement qui couvre tout lécran, indépendant du body.
*/
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
return (
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{children}
<div
data-public-scroll-root
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
>
{children}
</div>
</PublicProviders>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
import {
getAllEntitlementsForAdmin,
invalidateEntitlementCache,
ENTITLEMENT_UNAVAILABLE,
type SubscriptionTier as TierType,
} from '@/lib/plan-entitlements'
import { logAuditEventAsync } from '@/lib/audit-log'
@@ -18,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
@@ -53,7 +57,42 @@ export async function getBillingAdminData() {
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
const creditAllocations = TIERS.map((tier) => {
const raw = CREDIT_ALLOCATIONS[tier]
return {
tier,
monthlyCredits: raw === 'unlimited' ? null : raw,
unlimited: raw === 'unlimited',
}
})
const creditCosts = {
...CREDIT_COSTS,
slide_generate_example: slideGenerateCreditCost(7),
}
const creditPacks = CREDIT_PACK_IDS.map((id) => {
const p = CREDIT_PACKS[id]
return {
id: p.id,
credits: p.credits,
defaultDisplay: p.defaultDisplay,
}
})
return {
entitlements,
billingConfig,
usageOverview,
features: [...VALID_FEATURES],
tiers: TIERS,
creditAllocations,
creditCosts,
creditPacks,
}
}
export async function updatePlanEntitlement(
@@ -73,9 +112,20 @@ export async function updatePlanEntitlement(
}
if (mode === 'unavailable') {
await prisma.planEntitlement.deleteMany({
where: { tier: tier as SubscriptionTier, feature },
})
await prisma.planEntitlement.upsert({
where: {
tier_feature: {
tier: tier as SubscriptionTier,
feature,
},
},
update: { limitValue: ENTITLEMENT_UNAVAILABLE },
create: {
tier: tier as SubscriptionTier,
feature,
limitValue: ENTITLEMENT_UNAVAILABLE,
},
});
} else {
await prisma.planEntitlement.upsert({
where: {
@@ -158,7 +208,7 @@ export async function updateBillingConfig(data: Record<string, string>) {
async function getUsageOverviewInternal() {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01`)
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
const aggregated = await prisma.usageLog.groupBy({
by: ['feature'],

View File

@@ -42,24 +42,47 @@ export async function getSystemConfig() {
return getCachedConfig()
}
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
const CLEARABLE_CONFIG_KEYS = new Set([
'AI_PROVIDER_SLIDES',
'AI_MODEL_SLIDES',
])
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
// Filter out empty values but keep 'false' as valid.
// Slides provider/model may be empty intentionally (= use Chat fallback).
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
toDelete.push(key)
continue
}
if (value === '') continue
toUpsert[key] = value
}
await prisma.$transaction(operations)
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
...toDelete.map((key) =>
prisma.systemConfig.deleteMany({ where: { key } }),
),
]
if (operations.length > 0) {
await prisma.$transaction(operations)
}
return { success: true }
} catch (error) {

View File

@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
throw new Error('Failed to update subscription')
}
}
/**
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
*/
export async function resetUserQuotas(userId: string, feature?: string) {
const session = await checkAdmin()
if (!userId?.trim()) {
throw new Error('Identifiant utilisateur manquant')
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
})
if (!user) {
throw new Error('Utilisateur introuvable')
}
// Nouveau système : solde de crédits global
const { adminResetCredits } = await import('@/lib/credits')
const balance = await adminResetCredits(userId, { clearPurchased: false })
// Ancien système feature (compat / métriques)
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
const legacy = await resetUserUsageForCurrentPeriod(
userId,
feature ? { features: [feature] } : undefined,
)
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'QUOTA_RESET',
resource: userId,
metadata: {
targetUserId: userId,
targetEmail: user.email,
period: balance.period,
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
legacyFeaturesReset: legacy.featuresReset,
scope: feature || 'all',
},
})
revalidatePath('/admin')
revalidatePath('/admin/users')
return {
success: true as const,
period: balance.period,
featuresReset: legacy.featuresReset,
redisDeleted: legacy.redisDeleted,
dbUpdated: legacy.dbUpdated,
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
}
}

View File

@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
// Load module first so import failures surface immediately (not silently after response)
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
void executeAgent(id, userId)
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
}
try {
const existing = await prisma.agent.findFirst({
where: { id, userId: session.user.id },
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
})
if (!existing) throw new Error('Agent introuvable')
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
const nextRun = calculateNextRun({
frequency: existing.frequency,
scheduledTime: existing.scheduledTime,
scheduledDay: existing.scheduledDay,
timezone: existing.timezone,
})
data.nextRun = nextRun
}
const agent = await prisma.agent.update({
where: { id, userId: session.user.id },
data: { isEnabled }
data,
})
return { success: true, agent }
} catch (error) {

View File

@@ -4,6 +4,8 @@ import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
import { assertMcpAccess, isMcpTierAllowed } from '@/lib/mcp-access'
import { getEffectiveTier } from '@/lib/entitlements'
const KEY_PREFIX = 'mcp_key_'
@@ -62,6 +64,8 @@ export async function generateMcpKey(name: string): Promise<{ rawKey: string; in
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await assertMcpAccess(session.user.id)
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
@@ -155,6 +159,13 @@ export type McpServerStatus = {
url: string | null
}
export async function getMcpAccessStatus(): Promise<{ allowed: boolean; tier: string }> {
const session = await auth()
if (!session?.user?.id) return { allowed: false, tier: 'BASIC' }
const tier = await getEffectiveTier(session.user.id)
return { allowed: isMcpTierAllowed(tier), tier }
}
/**
* Get MCP server status — mode and URL.
*/

View File

@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
}
}
// Get count of unassigned notes (Inbox count) — notes without a notebook
export async function getInboxCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const count = await prisma.note.count({
where: {
userId: session.user.id,
notebookId: null,
isArchived: false,
trashedAt: null,
},
})
return count
} catch (error) {
console.error('Error counting inbox notes:', error)
return 0
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();

View File

@@ -5,6 +5,8 @@ import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { revalidatePath } from 'next/cache'
import { withAiQuota } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
export interface OrganizationNote {
id: string
@@ -134,8 +136,16 @@ Format de réponse JSON attendu:
let rawResponse: string
try {
rawResponse = await provider.generateText(prompt)
rawResponse = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
} catch (aiErr) {
if (aiErr instanceof QuotaExceededError) {
return { success: false, error: 'Quota IA épuisé pour cette fonctionnalité.' }
}
console.error('[organize-notebook] AI generateText failed:', aiErr)
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
}

View File

@@ -1,8 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import {
encodeSlideIntentDescription,
normalizeSlideIntent,
type SlideAudience,
type SlidePurpose,
} from '@/lib/ai/services/slide-intent'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
tools: [],
maxSteps: 1,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -32,36 +42,100 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language, template } = body as {
noteId: string
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId?: string
/** Si fourni (slides), génère un deck multi-notes du carnet */
notebookId?: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
purpose?: SlidePurpose
audience?: SlideAudience
slideCount?: number
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
if (notebookId && type !== 'slide-generator') {
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
}
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
let noteCountForPack = 1
if (notebookId) {
noteCountForPack = await prisma.note.count({
where: {
notebookId,
userId,
isArchived: false,
trashedAt: null,
},
})
if (noteCountForPack < 1) {
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
}
}
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost =
type === 'slide-generator'
? notebookId
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
: slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey)
try {
await reserveUsageOrThrow(userId, featureKey)
await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: {
noteId: noteId ?? null,
notebookId: notebookId ?? null,
type,
slideCount: slideCount ?? null,
noteCount: noteCountForPack,
},
lane: 'chat',
})
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })
return NextResponse.json(
{
error: e.message,
code: 'QUOTA_EXCEEDED',
feature: featureKey,
creditCost,
},
{ status: 402 },
)
}
throw e
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
let note: { id: string; title: string | null; notebookId: string | null } | null = null
if (noteId) {
note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
} else if (notebookId) {
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId },
select: { id: true, name: true },
})
if (!notebook) {
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
}
note = {
id: notebookId,
title: notebook.name,
notebookId,
}
}
const defaults = TYPE_DEFAULTS[type]
@@ -71,31 +145,62 @@ export async function POST(req: NextRequest) {
if (isEn) {
if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
}
if (!note) {
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
}
const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const slideIntent =
type === 'slide-generator'
? normalizeSlideIntent({
purpose,
audience,
slideCount,
template: template || 'auto',
})
: null
// Map purpose → legacy template when user picks intent without template
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
const purposeToTemplate: Record<string, string> = {
board: 'board-update',
project: 'project-status',
strategy: 'strategy-review',
}
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
slideIntent.template = purposeToTemplate[slideIntent.purpose]
}
}
const sourceNotebookId = notebookId || note.notebookId || undefined
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
description:
type === 'slide-generator' && slideIntent
? encodeSlideIntentDescription(slideIntent)
: undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
sourceNotebookId: notebookId ? notebookId : undefined,
targetNotebookId: sourceNotebookId,
slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft',
userId,
@@ -104,19 +209,31 @@ export async function POST(req: NextRequest) {
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
.catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: featureKey,
metadata: { agentId: agent.id, noteId, featureKey },
metadata: {
agentId: agent.id,
noteId: noteId ?? null,
notebookId: notebookId ?? null,
featureKey,
creditCost,
},
ip: getClientIp(req),
})
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
return NextResponse.json({
success: true,
agentId: agent.id,
status: 'running',
creditCost,
})
}
// ─── GET : poll current agent status ──────────────────────────────────────

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.accept(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true, agentId: result.agentId })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.dismiss(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
return NextResponse.json({
suggestions: suggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try {
return JSON.parse(s.relatedNoteIds).length
} catch {
return 0
}
})(),
clusterId: s.clusterId,
createdAt: s.createdAt.toISOString(),
})),
})
}

View File

@@ -4,6 +4,7 @@ import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/auto-labels - Suggest new labels for a notebook
@@ -59,11 +60,16 @@ export async function POST(request: NextRequest) {
)
}
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
const suggestions = await withAiQuota(
session.user.id,
language
'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
session.user!.id,
language,
),
{ lane: 'tags' },
)
if (!suggestions) {
@@ -79,8 +85,10 @@ export async function POST(request: NextRequest) {
data: suggestions,
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/auto-labels] POST failed:', error)
return NextResponse.json({ success: true, data: null })
return NextResponse.json({ success: false, error: 'auto_labels_failed' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
@@ -40,6 +41,8 @@ export async function POST(request: NextRequest) {
}
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id,

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -11,7 +12,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
@@ -27,23 +27,31 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
}
const result = await describeImages(
imageUrls,
mode === 'title' ? 'title' : 'description',
language || 'fr'
const isTitleMode = mode === 'title'
const feature = isTitleMode ? 'auto_title' : 'reformulate'
const lane = isTitleMode ? 'tags' : 'chat'
const result = await withAiQuota(
session.user.id,
feature,
() => describeImages(
imageUrls,
isTitleMode ? 'title' : 'description',
language || 'fr'
),
{ lane },
)
// For title mode, return suggestions in same format as /api/ai/title-suggestions
if (mode === 'title' && result.suggestions) {
if (isTitleMode && result.suggestions) {
return NextResponse.json({ suggestions: result.suggestions })
}
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[describe-image] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to describe image' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to describe image'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -4,6 +4,7 @@ import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/echo/fusion
@@ -34,7 +35,6 @@ export async function POST(req: NextRequest) {
)
}
// Fetch the notes
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
@@ -55,11 +55,9 @@ export async function POST(req: NextRequest) {
)
}
// Get AI provider
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Build fusion prompt
const notesDescriptions = notes.map((note, index) => {
return `Note ${index + 1}: "${note.title || 'Untitled'}"
${note.content}`
@@ -90,21 +88,21 @@ Output format:
Begin:`
try {
const fusedContent = await provider.generateText(fusionPrompt)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate fusion' },
{ status: 500 }
)
}
const fusedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(fusionPrompt),
{ lane: 'chat' },
)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/echo/fusion] Error:', error)
return NextResponse.json(
{ error: 'Failed to process fusion request' },
{ status: 500 }

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* GET /api/ai/echo
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
}
// Get next insight (respects frequency limits)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const insight = await memoryEchoService.getNextInsight(session.user.id)
if (!insight) {
@@ -41,6 +43,9 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ insight })
} catch (error) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
console.error('[/api/ai/echo] GET error:', error)
return NextResponse.json(
{ error: 'Failed to fetch Memory Echo insight' },

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
let prompt: string
if (mode === 'complete') {
// Add missing info from resource without rewriting the existing note
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -56,7 +56,6 @@ INSTRUCTIONS:
- Format the new content consistently with the existing note style and the requested FORMAT RULE
- Respond ONLY with the enriched note content, no explanations`
} else {
// Merge: intelligently rewrite integrating both sources
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -81,14 +80,19 @@ INSTRUCTIONS:
- Respond ONLY with the merged content, no meta-commentary or explanations`
}
const enrichedContent = await provider.generateText(prompt)
const enrichedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({ enrichedContent: enrichedContent.trim() })
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[enrich-from-resource] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to enrich content' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to enrich content'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/notebook-summary - Generate summary for a notebook
@@ -51,6 +52,8 @@ export async function POST(request: NextRequest) {
)
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Generate summary
const summary = await notebookSummaryService.generateSummary(
notebookId,

View File

@@ -33,19 +33,35 @@ export async function POST(request: NextRequest) {
throw err
}
// language = UI locale (fallback only). Content language = language of the topic string.
const result = await notebookWizardService.generateCarnet(
profile as WizardProfile,
topic,
{ level, count, language: language || 'fr' }
)
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
const resolvedName = (
result.notebookName?.trim()
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|| result.notes[0]?.title?.trim()
|| topic.trim().slice(0, 60)
).slice(0, 80)
// Place new notebook at top of manual order (before existing min order)
const minOrder = await prisma.notebook.aggregate({
where: { userId: session.user.id, trashedAt: null },
_min: { order: true },
})
const nextOrder = (minOrder._min.order ?? 0) - 1
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
name: resolvedName,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,
order: nextOrder,
},
})
@@ -87,6 +103,8 @@ export async function POST(request: NextRequest) {
notebookId: notebook.id,
type: 'richtext',
order: noteIndex++,
autoGenerated: true,
aiProvider: 'notebook-wizard',
},
})

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { syncNoteLabels } from '@/app/actions/notes'
export async function POST(request: NextRequest) {
@@ -17,19 +17,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
try {
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json(
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
{ status: 402 },
)
}
throw err
}
const notes = await prisma.note.findMany({
where: { notebookId, trashedAt: null, userId: session.user.id },
select: { id: true, title: true, content: true },
@@ -46,13 +33,20 @@ export async function POST(request: NextRequest) {
contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
}))
const result = await notebookOrganizerService.analyze(notesForAnalysis)
const result = await withAiQuota(
session.user.id,
'reformulate',
() => notebookOrganizerService.analyze(notesForAnalysis),
{ lane: 'chat' },
)
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Notebook Organizer] Error:', error)
return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Failed to organize notebook'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -15,11 +15,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
}
const overview = await searchOverviewService.generate(query, results, language)
const overview = await withAiQuota(
session.user.id,
'semantic_search',
() => searchOverviewService.generate(query, results, language),
{ lane: 'chat' },
)
return NextResponse.json(overview)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Search Overview] Error:', error)
return NextResponse.json({ error: error.message, hasRelevantInfo: false, answer: '' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Search overview failed'
return NextResponse.json({ error: message, hasRelevantInfo: false, answer: '' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -21,7 +22,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
}
// Minimum content length for suggestion (20 words as per specs)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
return NextResponse.json({
@@ -31,18 +31,25 @@ export async function POST(req: NextRequest) {
})
}
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
const suggestedNotebook = await withAiQuota(
session.user.id,
language
'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
session.user!.id,
language
),
{ lane: 'tags' },
)
return NextResponse.json({
suggestion: suggestedNotebook,
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
confidence: suggestedNotebook ? 0.8 : 0
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/suggest-notebook] Error:', error)
return NextResponse.json(
{ error: 'Failed to generate suggestion' },
{ status: 500 }

View File

@@ -1,10 +1,11 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
@@ -61,19 +62,16 @@ export async function POST(req: NextRequest) {
}
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
if (!willUseByok) {
try {
await reserveUsageOrThrow(session.user.id, 'auto_title')
} 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/title-suggestions] Quota check error (fail-open):', err)
try {
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
} 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/title-suggestions] Quota check error (fail-open):', err)
}
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -18,12 +19,10 @@ export async function POST(request: NextRequest) {
const { text } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Validate word count
const wordCount = text.split(/\s+/).length
if (wordCount < 10) {
return NextResponse.json(
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Detect language from text
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
const responseLanguage = hasFrench ? 'French' : 'English'
// Build prompt to transform text to Markdown
const prompt = hasFrench
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
@@ -77,19 +74,22 @@ ${text}
Respond ONLY with the transformed Markdown text, no explanations.`
const transformedText = await provider.generateText(prompt)
const transformedText = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({
originalText: text,
transformedText: transformedText,
transformedText,
language: responseLanguage
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to transform text to Markdown' },
{ status: 500 }
)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
const message = error instanceof Error ? error.message : 'Failed to transform text to Markdown'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getTagsProvider(config)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
} catch (error: unknown) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
const message = error instanceof Error ? error.message : 'Translation failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { resolvePriceId } from '@/lib/billing/stripe-prices';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']),
/** Prefer hosted redirect when embedded checkout is unavailable */
mode: z.enum(['hosted', 'embedded']).optional(),
});
export async function POST(req: NextRequest) {
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
}
const secret = process.env.STRIPE_SECRET_KEY;
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
);
}
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id;
const userEmail = session.user.email;
try {
const priceId = await resolvePriceId(tier, interval);
let priceId: string;
try {
priceId = await resolvePriceId(tier, interval);
} catch (e) {
console.error('[billing/create-checkout] price resolve failed:', e);
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -42,7 +73,6 @@ export async function POST(req: NextRequest) {
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
});
}
@@ -61,29 +91,51 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
const embedded = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription' as const,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
return_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`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
});
if (checkoutSession.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url });
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
} catch (error) {
console.error('[billing/create-checkout]', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
import {
isCreditPackId,
resolvePackPriceId,
getCreditPack,
} from '@/lib/billing/credit-packs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
packId: z.enum(['S', 'M', 'L']),
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
}
const secret = process.env.STRIPE_SECRET_KEY
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
)
}
const parsed = bodySchema.safeParse(await req.json())
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
}
const packId = parsed.data.packId
const pack = getCreditPack(packId)
const userId = session.user.id
const userEmail = session.user.email
try {
let priceId: string
try {
priceId = await resolvePackPriceId(packId)
} catch (e) {
console.error('[billing/create-pack-checkout] price resolve failed:', e)
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
const subscription = await prisma.subscription.findUnique({ where: { userId } })
let customerId = subscription?.stripeCustomerId ?? undefined
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
})
customerId = customer.id
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
})
}
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const origin = `${proto}://${host}`
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
payment_intent_data: {
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
},
customer_update: { address: 'auto' },
allow_promotion_codes: true,
})
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
packId,
credits: pack.credits,
})
} catch (error) {
console.error('[billing/create-pack-checkout]', error)
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
export const dynamic = 'force-dynamic';
@@ -76,6 +75,42 @@ export async function GET(req: NextRequest) {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
const creditPacks = await getPackPublicPrices();
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
if (sessionId && sessionId.startsWith('cs_')) {
try {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (
checkoutSession.mode === 'payment' &&
checkoutSession.status === 'complete' &&
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
checkoutSession.metadata?.userId === userId
) {
const { addPurchasedCredits } = await import('@/lib/credits');
const {
getCreditPack,
isCreditPackId,
} = await import('@/lib/billing/credit-packs');
const packId = checkoutSession.metadata?.packId;
let credits = Number(checkoutSession.metadata?.credits ?? 0);
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (Number.isFinite(credits) && credits > 0) {
await addPurchasedCredits(userId, credits, {
stripeSessionId: checkoutSession.id,
packId: packId ?? null,
type: 'credit_pack',
source: 'billing_status_sync',
});
}
}
} catch (packSyncErr) {
console.error('[billing/status] pack sync failed:', packSyncErr);
}
}
return NextResponse.json({
tier,
@@ -86,6 +121,7 @@ export async function GET(req: NextRequest) {
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
prices,
creditPacks,
billingEnabled,
});
} catch (error) {

View File

@@ -6,11 +6,79 @@ import {
handleSubscriptionDeleted,
resolveUserIdFromStripeEvent,
} from '@/lib/billing/sync-subscription-from-stripe';
import { prisma } from '@/lib/prisma';
import { addPurchasedCredits } from '@/lib/credits';
import {
getCreditPack,
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
if (session.mode !== 'payment') return;
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
return;
}
const userId = session.metadata?.userId as string | undefined;
if (!userId) {
console.warn('[billing/webhook] credit pack: no userId', session.id);
return;
}
let packId = session.metadata?.packId as string | undefined;
let credits = Number(session.metadata?.credits ?? 0);
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
try {
const full = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items.data.price'],
});
const priceId = full.line_items?.data?.[0]?.price?.id;
if (priceId) {
const resolved = await resolvePackFromPriceId(priceId);
if (resolved) {
packId = resolved.packId;
credits = resolved.credits;
}
}
} catch (err) {
console.error('[billing/webhook] expand line_items failed', err);
}
}
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (!Number.isFinite(credits) || credits <= 0) {
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
return;
}
const result = await addPurchasedCredits(userId, credits, {
stripeSessionId: session.id,
packId: packId ?? null,
type: 'credit_pack',
paymentIntent:
typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id ?? null,
});
if (result.applied) {
console.info('[billing/webhook] credited pack', {
userId,
credits: result.units,
packId,
sessionId: session.id,
});
}
}
export async function POST(req: NextRequest) {
const body = await req.text();
const headersList = await headers();
@@ -49,6 +117,11 @@ export async function POST(req: NextRequest) {
} else {
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
}
} else if (
session.mode === 'payment' &&
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
) {
await fulfillCreditPackPurchase(session);
}
break;
}

View File

@@ -3,7 +3,8 @@ import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { verifyParticipant } from '@/lib/brainstorm-collab'
import { billingOwnerFromSession, verifyParticipant } from '@/lib/brainstorm-collab'
import { withSessionAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
@@ -53,6 +54,11 @@ export async function POST(
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
}
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
brainstormSession.userId,
session.user.id,
)
const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n')
@@ -79,15 +85,27 @@ Write a concise synthesis (4-6 sentences) that:
Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser(
'tags',
config,
const summary = await withSessionAiQuota(
billingOwnerId,
session.user.id,
(provider) => provider.generateText(prompt),
isGuestActor,
'brainstorm_enrich',
async () => {
const { result } = await runLaneWithBillingUser(
'tags',
config,
billingOwnerId,
(provider) => provider.generateText(prompt),
)
return result
},
{ lane: 'tags' },
)
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
}

View File

@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
interface PathsBriefingSnapshot {
recentNote?: {
id: string
title: string | null
content: string
notebookId: string | null
} | null
inboxCount?: number
dueFlashcards?: number
insights?: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions?: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions?: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
includeOpenLoops?: boolean
}
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
const now = new Date()
const hasSnapshot = !!snapshot?.recentNote
const needsAgents = !snapshot?.agentSuggestions
const needsBridges = !snapshot?.bridgeSuggestions
const needsInsights = !snapshot?.insights
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
const needsFocusNote = !snapshot?.recentNote
const [
aiConsent,
aiSettings,
systemConfig,
recentNotes,
inboxCount,
dueFlashcards,
unviewedInsights,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
needsFocusNote
? prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 1,
})
: Promise.resolve([]),
needsCounts
? prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
})
: Promise.resolve(snapshot!.inboxCount!),
needsCounts
? prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
})
: Promise.resolve(snapshot!.dueFlashcards!),
needsInsights
? prisma.memoryEchoInsight.findMany({
where: { userId, dismissed: false },
select: {
id: true, insight: true, similarityScore: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => [])
: Promise.resolve([]),
needsAgents
? agentSuggestionService.getPending(userId, 3).catch(() => [])
: Promise.resolve([]),
needsBridges
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
: Promise.resolve([]),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const focusNotes = hasSnapshot && snapshot?.recentNote
? [{
...snapshot.recentNote,
updatedAt: now,
}]
: recentNotes
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
}))
const resolvedBridges = snapshot?.bridgeSuggestions
?? bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
}))
const resolvedAgents = snapshot?.agentSuggestions
?? agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
clusterId: s.clusterId,
}))
const paths = await buildDashboardPaths({
userId,
aiActive,
recentNotes: focusNotes,
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
insights,
bridgeSuggestions: resolvedBridges,
agentSuggestions: resolvedAgents,
}).catch(() => [])
const openLoops = snapshot?.includeOpenLoops === false
? []
: await buildOpenLoops(userId).catch(() => [])
return { paths, openLoops }
}
/**
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await resolvePathsPayload(session.user.id)
return NextResponse.json(payload)
}
/**
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
*/
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
const payload = await resolvePathsPayload(session.user.id, snapshot)
return NextResponse.json(payload)
}

View File

@@ -0,0 +1,240 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
async function fetchWritingActivity(userId: string, since: Date) {
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
COUNT(*)::int AS count
FROM "Note"
WHERE "userId" = ${userId}
AND "trashedAt" IS NULL
AND "updatedAt" >= ${since}
GROUP BY 1
ORDER BY 1 ASC
`
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
}
/**
* GET /api/briefing — agrégat rapide pour le tableau de bord.
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const [
recentNotes,
inboxCount,
dueFlashcards,
upcomingReminders,
unviewedInsights,
recentAgentActions,
pinnedNotes,
writingActivity,
gmailStatus,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: {
id: true, title: true, content: true, color: true,
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 8,
}),
prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
prisma.note.findMany({
where: {
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
},
select: { id: true, title: true, reminder: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: 5,
}),
prisma.memoryEchoInsight.findMany({
where: { userId, viewed: false, dismissed: false },
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => []),
prisma.agentAction.findMany({
where: {
agent: { userId },
status: 'completed',
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
},
select: {
id: true, result: true, createdAt: true,
agent: { select: { id: true, name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
}).catch(() => []),
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
gmailScannerService.getStatus(userId).catch(() => ({
connected: false,
recentCaptures: 0,
})),
agentSuggestionService.getPending(userId, 3).catch(() => []),
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
])
let insightRows = unviewedInsights
if (insightRows.length < 5) {
const viewedRecent = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
viewed: true,
id: { notIn: insightRows.map(i => i.id) },
},
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5 - insightRows.length,
}).catch(() => [])
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
select: { id: true, name: true, color: true, icon: true },
})
: []
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
return NextResponse.json({
recentNotes: recentNotes.map(n => ({
...n,
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,
title: r.title,
reminder: r.reminder?.toISOString() || null,
notebookId: r.notebookId,
})),
insights: insightRows.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
date: i.insightDate.toISOString(),
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
})),
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
})),
ai: {
consent: aiConsent,
memoryEchoEnabled,
providerReady: aiProviderReady,
active: aiActive,
},
agentActions: recentAgentActions.map(a => ({
id: a.id,
agentName: a.agent?.name || 'Agent',
agentType: a.agent?.type || 'custom',
result: a.result ? String(a.result).slice(0, 200) : null,
createdAt: a.createdAt.toISOString(),
})),
gmail: gmailStatus,
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
})(),
clusterId: s.clusterId,
})),
})
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
const CACHE_TTL_SEC = 3600
/**
* GET /api/briefing/sentiment
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
try {
const cached = await redis.get(cacheKey)
if (cached) return NextResponse.json(JSON.parse(cached))
} catch {}
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentNotes = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
if (recentNotes.length < 3) {
return NextResponse.json({
available: false,
reason: 'Not enough notes this week',
})
}
const snippets = recentNotes.map(n => {
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
return `${n.title || ''}: ${text.slice(0, 200)}`
}).join('\n---\n')
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) {
return NextResponse.json({ available: false, reason: 'No AI provider' })
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
const result = await withAiQuota(
userId,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' })
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 402 })
}
console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' })
}
}

View File

@@ -1,6 +1,6 @@
import { streamText, UIMessage, stepCountIs } from 'ai'
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
@@ -9,7 +9,8 @@ import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
@@ -72,11 +73,7 @@ export async function POST(req: Request) {
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
try {
const sysConfigEarly = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
if (!willUseByok) {
await reserveUsageOrThrow(userId, 'chat')
}
await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 })

View File

@@ -18,10 +18,63 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Fix any leftover "Cluster N" placeholders from pre-save naming bug
await clusteringService.ensureClusterNames(userId)
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1'
if (stored) {
const cached = stored.clusters
if (lite) {
const [bridgeNotesData, totalNotes] = await Promise.all([
prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 5,
select: { noteId: true, bridgeScore: true, clustersConnected: true },
}),
prisma.note.count({ where: { userId, trashedAt: null } }),
])
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
select: { id: true, title: true },
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId),
}
})
}
return NextResponse.json({
clusters: cached,
bridgeNotes: enrichedBridgeNotes,
cached: true,
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes,
})
}
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
@@ -66,8 +119,11 @@ export async function GET(request: NextRequest) {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
clusterNames: clustersConnected.map(cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId)
}
@@ -153,12 +209,10 @@ export async function POST(request: NextRequest) {
})
}
// 2. Generate cluster names with AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// 2. Name from in-memory members (DB members do not exist until save)
await clusteringService.nameClustersFromResults(userId, results)
// 3. Save clustering results
// 3. Save clustering results (with real names)
await clusteringService.saveClusteringResults(userId, results)
// 4. Detect and save bridge notes

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export const dynamic = 'force-dynamic'
function authorizeCron(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const querySecret = new URL(request.url).searchParams.get('secret')
return bearer === cronSecret || querySecret === cronSecret
}
/**
* POST /api/cron/agent-suggestions
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
*/
export async function POST(request: NextRequest) {
if (!authorizeCron(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { noteClusters: { some: {} } },
select: { id: true },
take: 30,
})
let total = 0
for (const user of users) {
try {
total += await agentSuggestionService.generateForUser(user.id)
} catch (err) {
console.error('[CronAgentSuggestions] user', user.id, err)
}
}
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
}

View File

@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
try {
const now = new Date()
// Due = nextRun in the past; also backfill agents whose nextRun was never set
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
const dueAgents = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { not: 'manual' },
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now },
},
select: {
@@ -40,23 +42,77 @@ export async function POST(request: NextRequest) {
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
orderBy: { nextRun: 'asc' },
})
if (dueAgents.length === 0) {
return NextResponse.json({ success: true, executed: 0 })
// Repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: null,
},
select: {
id: true,
userId: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
},
take: 50,
})
const repairedDue: typeof dueAgents = []
for (const a of unscheduled) {
const nextRun = calculateNextRun({
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
})
// Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
const effective = !nextRun || nextRun <= now ? now : nextRun
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
if (effective <= now) {
repairedDue.push({
id: a.id,
userId: a.userId,
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
nextRun: effective,
})
}
}
const queue = [...dueAgents]
const seen = new Set(dueAgents.map((a) => a.id))
for (const a of repairedDue) {
if (!seen.has(a.id)) {
queue.push(a)
seen.add(a.id)
}
}
if (queue.length === 0) {
return NextResponse.json({
success: true,
executed: 0,
repairedSchedules: unscheduled.length,
})
}
const results: { id: string; success: boolean; error?: string }[] = []
// Execute agents sequentially (max 3 per cycle)
for (const agent of dueAgents.slice(0, 3)) {
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
for (const agent of queue.slice(0, 5)) {
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
@@ -64,7 +120,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone,
})
await prisma.agent.update({
where: { id: agent.id },
data: { nextRun },
@@ -75,7 +130,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,

View File

@@ -102,10 +102,8 @@ export async function GET(request: NextRequest) {
continue
}
// Generate cluster names
for (const cluster of clusterResults.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
}
// Name from in-memory members (before save — generateClusterName needs DB rows)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
// Save results
await clusteringService.saveClusteringResults(user.id, clusterResults)
@@ -174,9 +172,9 @@ export async function POST(request: NextRequest) {
if (userId) {
// Process specific user
const clusterResults = await clusteringService.clusterNotes(userId)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await clusteringService.nameClustersFromResults(userId, clusterResults)
await clusteringService.saveClusteringResults(userId, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
return NextResponse.json({
@@ -202,9 +200,9 @@ export async function POST(request: NextRequest) {
for (const user of users) {
try {
const clusterResults = await clusteringService.clusterNotes(user.id)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
await clusteringService.saveClusteringResults(user.id, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
results.processed++

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
export const dynamic = 'force-dynamic'
function authorize(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
return bearer === cronSecret
}
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
export async function POST(request: NextRequest) {
if (!authorize(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.userAISettings.findMany({
select: { userId: true, integrationTokens: true },
take: 200,
})
const connected = users.filter(u => {
if (!u.integrationTokens) return false
try {
const meta = typeof u.integrationTokens === 'string'
? JSON.parse(u.integrationTokens)
: u.integrationTokens
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
} catch {
return false
}
})
let totalCreated = 0
for (const row of connected) {
try {
const { created } = await gmailScannerService.scanUser(row.userId)
totalCreated += created
} catch (err) {
console.error('[CronGmail]', row.userId, err)
}
}
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
}

View File

@@ -77,12 +77,12 @@ export async function POST(req: NextRequest) {
redis.get(`${key}:tokens`),
]);
const periodStart = new Date(`${period}-01`);
const periodEnd = new Date(
periodStart.getFullYear(),
periodStart.getMonth() + 1,
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
const periodEnd = new Date(Date.UTC(
periodStart.getUTCFullYear(),
periodStart.getUTCMonth() + 1,
1,
);
));
await prisma.usageLog.upsert({
where: {

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import {
getDefaultDashboardLayout,
normalizeDashboardLayout,
type DashboardLayout,
} from '@/lib/dashboard/layout'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { dashboardLayout: true },
})
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
return NextResponse.json({ layout })
}
export async function PUT(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: { layout?: DashboardLayout }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
const layoutJson = layout as unknown as Prisma.InputJsonValue
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: { userId: session.user.id, dashboardLayout: layoutJson },
update: { dashboardLayout: layoutJson },
})
return NextResponse.json({ layout, ok: true })
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { getGoogleOAuthCredentials, readIntegrationMeta, writeIntegrationMeta } from '@/lib/integrations/google-integration-tokens'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const userId = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error || !code || !userId) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_auth_failed`)
}
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${url.origin}/api/integrations/gmail/callback`,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_token_failed`)
}
const meta = await readIntegrationMeta(userId)
meta.gmailAccessToken = tokenData.access_token
if (tokenData.refresh_token) meta.gmailRefreshToken = tokenData.refresh_token
await writeIntegrationMeta(userId, meta)
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=gmail`)
}

View File

@@ -0,0 +1,67 @@
/**
* Google Gmail Integration (OAuth + scan trigger)
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import {
getGoogleOAuthCredentials,
getGoogleTokens,
readIntegrationMeta,
writeIntegrationMeta,
} from '@/lib/integrations/google-integration-tokens'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
function redirectUri(origin: string) {
return `${origin}/api/integrations/gmail/callback`
}
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(req.url)
const userId = session.user.id
if (url.searchParams.get('connect') === '1') {
const { clientId } = getGoogleOAuthCredentials()
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
}
const authUrl = new URL(GOOGLE_AUTH_URL)
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri(url.origin))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', GMAIL_SCOPE)
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('state', userId)
return NextResponse.redirect(authUrl.toString())
}
if (url.searchParams.get('scan') === '1') {
const result = await gmailScannerService.scanUser(userId)
return NextResponse.json(result)
}
const status = await gmailScannerService.getStatus(userId)
return NextResponse.json(status)
}
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const meta = await readIntegrationMeta(session.user.id)
delete meta.gmailAccessToken
delete meta.gmailRefreshToken
await writeIntegrationMeta(session.user.id, meta)
return NextResponse.json({ success: true })
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMobileUserId } from '@/lib/mobile-auth'
import { getSystemConfig } from '@/lib/config'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(req: NextRequest) {
const userId = await getMobileUserId(req)
@@ -18,6 +19,15 @@ export async function POST(req: NextRequest) {
const apiKey = config.OPENAI_API_KEY
if (!apiKey) return NextResponse.json({ error: 'Service non disponible' }, { status: 503 })
try {
await reserveUsageOrThrow(userId, 'voice_transcribe')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 429 })
}
throw err
}
const whisperForm = new FormData()
whisperForm.append('file', file, 'audio.m4a')
whisperForm.append('model', 'whisper-1')

View File

@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/auth'
import { writeUploadMeta } from '@/lib/upload-access'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
}
const buffer = Buffer.from(await file.arrayBuffer())
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
let ext = path.extname(file.name).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
const mimeToExt: Record<string, string> = {
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
}
ext = mimeToExt[file.type] || '.png'
}
const filename = `${randomUUID()}${ext}`
// Ensure directory exists (data/uploads is outside public/, served via API route)
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
const filename = `${randomUUID()}${ext}`
// Ownership in path: /uploads/notes/{userId}/{filename}
const relativeUnderNotes = `${userId}/${filename}`
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await writeFile(filePath, buffer)
await writeUploadMeta(relativeUnderNotes, userId)
const url = `/uploads/notes/${relativeUnderNotes}`
return NextResponse.json({
success: true,
url: `/uploads/notes/${filename}`
url,
})
} catch (error) {
console.error('[upload]', error)
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
{ status: 500 },
)
}
}

View File

@@ -21,16 +21,30 @@ export async function GET(
const session = await auth()
const { path: segments } = await params
// Only serve from uploads/notes/ subdirectory
if (segments[0] !== 'notes') {
// Only serve from uploads/notes/ (flat or userId/file)
if (!segments.length || segments[0] !== 'notes') {
return new NextResponse('Not found', { status: 404 })
}
// Reject path traversal
if (segments.some((s) => s === '..' || s.includes('\0'))) {
return new NextResponse('Forbidden', { status: 403 })
}
const filename = segments[segments.length - 1]
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
// Never serve meta sidecars as images
if (filename.endsWith('.meta.json')) {
return new NextResponse('Not found', { status: 404 })
}
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
if (!allowed) {
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
status: session?.user?.id ? 403 : 401,
headers: {
// Do not let browsers / CDNs cache a denial (would stick a broken image)
'Cache-Control': 'no-store',
},
})
}
@@ -40,9 +54,8 @@ export async function GET(
return new NextResponse('Unsupported file type', { status: 400 })
}
// Prevent path traversal
const safePath = path.join(UPLOAD_DIR, ...segments)
if (!safePath.startsWith(UPLOAD_DIR)) {
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
return new NextResponse('Forbidden', { status: 403 })
}
@@ -57,7 +70,8 @@ export async function GET(
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
// private: only for authenticated session fetches; still long cache after auth ok
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(buffer.length),
},
})

View File

@@ -1,27 +1,48 @@
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
export const dynamic = 'force-dynamic';
export const dynamic = 'force-dynamic'
export async function GET() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const [quotas, tier] = await Promise.all([
getUserQuotas(session.user.id),
getEffectiveTier(session.user.id),
]);
return NextResponse.json({ quotas, tier });
const summary = await getCreditUsageSummary(session.user.id)
// Compat anciens clients : quotas dérivés des crédits
const quotas = await getQuotasCompatFromCredits(session.user.id)
return NextResponse.json({
tier: summary.tier,
period: summary.period,
balance: {
totalRemaining: summary.balance.unlimited
? null
: summary.balance.totalRemaining,
subscriptionRemaining: summary.balance.unlimited
? null
: summary.balance.subscriptionRemaining,
purchasedRemaining: summary.balance.purchasedRemaining,
subscriptionGranted: summary.balance.unlimited
? null
: summary.balance.subscriptionGranted,
subscriptionSpent: summary.balance.subscriptionSpent,
spentThisPeriod: summary.balance.spentThisPeriod,
unlimited: summary.balance.unlimited,
},
breakdown: summary.breakdown,
// legacy
quotas,
})
} catch (error) {
console.error('[usage/current] Failed to fetch quotas:', error);
console.error('[usage/current] Failed to fetch usage data:', error)
return NextResponse.json(
{ error: 'Failed to fetch usage data' },
{ status: 503 },
);
)
}
}

View File

@@ -4,7 +4,21 @@
@import "vazirmatn/Vazirmatn-font-face.css";
@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark *));
/* Inclure .dark lui-même + ses descendants (évite styles clairs manquants) */
@custom-variant dark (&:where(.dark, .dark *));
/* Filet de sécurité anti-flash : fond sombre dès que html a .dark,
même si une frame Tailwind na pas encore appliqué dark: sur un enfant. */
html.dark {
color-scheme: dark;
background-color: #202020;
}
html.dark body {
color-scheme: dark;
background-color: #202020;
color: #ffffff;
}
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
@@ -1623,7 +1637,11 @@ html.font-system * {
height: auto;
display: block;
margin: 0.5em 0;
transition: filter 0.15s ease;
transition: filter 0.15s ease, outline 0.15s ease;
cursor: pointer;
/* Ensure clicks select the node (not swallowed by surrounding layout) */
position: relative;
z-index: 1;
}
.notion-editor-wrapper .ProseMirror img:hover {
@@ -1635,6 +1653,14 @@ html.font-system * {
outline-offset: 2px;
}
/* Image node wrapper when selected as block */
.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img,
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
}
/* --- Links --- */
.notion-editor-wrapper .ProseMirror a {
color: var(--primary);

View File

@@ -5,14 +5,17 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
import { getAISettings } from "@/app/actions/ai-settings";
import { getUserSettings } from "@/app/actions/user-settings";
import { ThemeInitializer } from "@/components/theme-initializer";
import { ThemeClassGuard } from "@/components/theme-class-guard";
import { DirectionInitializer } from "@/components/direction-initializer";
import { ErrorReporter } from "@/components/error-reporter";
import { auth } from "@/auth";
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
import Script from "next/script";
import type { CSSProperties } from "react";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { cookies } from "next/headers";
import { normalizeThemeId, THEME_COOKIE_NAME } from "@/lib/apply-document-theme";
import { THEME_INIT_SCRIPT, DIRECTION_INIT_SCRIPT } from "@/lib/inline-boot-scripts";
import { SwCleanup } from "@/components/sw-cleanup";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
@@ -76,16 +79,29 @@ export default async function RootLayout({
const session = await auth();
const userId = session?.user?.id;
const [aiSettings, userSettings] = await Promise.all([
const [aiSettings, userSettings, cookieStore] = await Promise.all([
getAISettings(userId),
getUserSettings(userId),
cookies(),
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
// Cookie = préférence immédiate (bascule barre latérale) ; base = profil utilisateur.
// Sans cookie, un dark seulement en localStorage était écrasé par le serveur → flash clair.
const cookieTheme = cookieStore.get(THEME_COOKIE_NAME)?.value
const resolvedTheme = normalizeThemeId(cookieTheme || userSettings.theme || 'light')
const htmlTheme = serverHtmlThemeState(resolvedTheme)
const serverAccent = userSettings.accentColor ?? '#A47148'
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
const serverTheme = resolvedTheme
const wantsDarkClass =
resolvedTheme === 'dark' ||
resolvedTheme === 'midnight' ||
// auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client
false
const htmlStyle = {
'--color-brand-accent': serverAccent,
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
} as CSSProperties
return (
@@ -97,14 +113,18 @@ export default async function RootLayout({
data-server-accent={serverAccent}
style={htmlStyle}
>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden`}>
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
<head>
{/* Scripts boot inline : anti-flash thème/RTL, sans next/script (React 19). */}
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script id="direction-init" dangerouslySetInnerHTML={{ __html: DIRECTION_INIT_SCRIPT }} />
</head>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden bg-background text-foreground`}>
<SessionProviderWrapper>
<SwCleanup />
<ErrorReporter />
<DirectionInitializer />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeInitializer theme={resolvedTheme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeClassGuard />
{children}
<LanguageProvider initialLanguage="en">
<CookieConsentRoot />

View File

@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4',
className
)}
>
{metrics.map((metric, index) => (
<Card
key={index}
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
className="p-6 bg-card border-border"
>
<div className="flex items-start justify-between">
<div className="flex-1">

View File

@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'Custom API', hint: 'Your own endpoint' },
}
function displayName(provider: string): string {
@@ -59,7 +59,7 @@ type SavedKey = {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) throw new Error('Erreur de chargement')
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
@@ -92,6 +92,8 @@ function EditKeyForm({
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { t } = useLanguage()
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
@@ -118,10 +120,10 @@ function EditKeyForm({
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
onInvalidate()
onDone()
},
@@ -132,7 +134,7 @@ function EditKeyForm({
const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return
if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
toast.info(t('byok.testHint'))
return
}
setTesting(true)
@@ -143,7 +145,7 @@ function EditKeyForm({
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
setTestResult({ ok: false, error: t('byok.contactError') })
} finally {
setTesting(false)
}
@@ -155,18 +157,18 @@ function EditKeyForm({
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
Modifier {displayName(savedKey.provider)}
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
@@ -174,21 +176,21 @@ function EditKeyForm({
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('common.loading')}</div>}
</div>
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
@@ -196,7 +198,7 @@ function EditKeyForm({
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
placeholder={t('byok.keyPlaceholder')}
/>
</div>
@@ -209,8 +211,8 @@ function EditKeyForm({
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span>
{testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec'
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? t('common.error')
}
</span>
</div>
@@ -225,7 +227,7 @@ function EditKeyForm({
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester
{t('byok.test')}
</button>
<Button
type="button"
@@ -233,7 +235,7 @@ function EditKeyForm({
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button>
<button
type="button"
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
async function verifyKey() {
if (!provider || apiKey.length < 8) return
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error(t('byok.baseUrlRequired')); return }
setVerifying(true)
setTestResult(null)
try {
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
if (res.ok && body.valid) {
setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
toast.success('Clé API valide ✓')
toast.success(t('byok.keyValid'))
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
toast.error(body.message ?? t('byok.keyInvalid'))
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
} catch { setKeyOk(false); toast.error(t('byok.verifyError')) }
finally { setVerifying(false) }
}
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
} catch { setTestResult({ ok: false, error: t('byok.contactError') }) }
finally { setTesting(false) }
}
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
toast.success(t('byok.keySaved', { name: displayName(provider) }))
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({
mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onSuccess: () => { toast.success(t('byok.keyDeleted')); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
@@ -417,9 +419,9 @@ export function ByokSettingsPanel() {
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Memento</span></>
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)}
</div>
)}
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
@@ -448,7 +450,7 @@ export function ByokSettingsPanel() {
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? '● Actif' : '○ Inactif'}
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span>
</div>
</div>
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
type="button"
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -555,19 +557,19 @@ export function ByokSettingsPanel() {
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button>
</div>
</div>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
<SelectTrigger id="byok-model"><SelectValue placeholder={t('byok.chooseModel')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
@@ -582,8 +584,8 @@ export function ByokSettingsPanel() {
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
<div className="space-y-0.5">
{testResult.ok ? (
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
<><p className="font-semibold">{t('byok.modelOperational')}{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">{t('byok.reply')} {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
</div>
</div>
)}
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
{provider ? t('byok.saveWithName', { name: displayName(provider) }) : t('byok.save')}
</div>
</Button>
</div>

View File

@@ -406,7 +406,12 @@ export function BlockActionMenu({
{/* Création de diagramme */}
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
<span className="font-medium text-amber-700 dark:text-amber-400 flex flex-col items-start gap-0.5">
<span>{t('blockAction.createDiagram')}</span>
<span className="text-[9px] font-normal text-muted-foreground normal-case tracking-normal">
{t('blockAction.createDiagramCostHint')}
</span>
</span>
</button>
<div className="block-action-separator" />

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface BlockSuggestion {
blockId: string
@@ -22,6 +23,7 @@ interface BlockPickerProps {
}
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
const { t } = useLanguage()
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Rechercher un extrait de note..."
placeholder={t('blockPicker.searchPlaceholder')}
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
autoFocus
/>

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react'
import { Zap, Lightbulb, Trophy } from 'lucide-react'
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
}
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
const { t } = useLanguage()
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
@@ -109,14 +111,14 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.bridges')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-ochre mb-2">
<Lightbulb size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.suggestions')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
</div>
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.powerfulTitle')}</h3>
</div>
<div className="space-y-3">
{bridgeNotes.map((bridge) => (
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
{bridge.note?.title || 'Untitled'}
{bridge.note?.title || t('common.untitled')}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
{t('bridgeNotes.score')}: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
</motion.div>
))}
{bridgeNotes.length === 0 && (
<div className="text-xs text-concrete italic p-4">No significant bridge notes found yet. Deepen your research to find new connections.</div>
<div className="text-xs text-concrete italic p-4">{t('bridgeNotes.empty')}</div>
)}
</div>
</section>
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.missingLinks')}</h3>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
@@ -180,7 +182,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
{t('bridgeNotes.bridging')} {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
@@ -193,7 +195,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<button
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
title="Dismiss suggestion"
title={t('bridgeNotes.dismiss')}
>
×
</button>
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
{suggestions.length === 0 && !loading && (
<div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p>
<p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
<p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
<button
onClick={generateNewSuggestions}
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
>
Generate Suggestions
{t('bridgeNotes.generate')}
</button>
</div>
)}

View File

@@ -6,6 +6,7 @@ import { NoteChart } from './note-chart'
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
// Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = {
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
onClose,
onSelectChart,
}: ChartSuggestionsDialogProps) {
const { t } = useLanguage()
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -62,7 +64,7 @@ export function ChartSuggestionsDialog({
console.error('[ChartSuggestionsDialog] Error:', err)
setResponse({
suggestions: [],
analyzedText: textToAnalyze?.substring(0, 100) || '',
analyzedText: (selection || content || '').substring(0, 100),
detectedData: 'Error occurred',
hasData: false,
error: err.message || 'Network error - check console',
@@ -122,25 +124,28 @@ export function ChartSuggestionsDialog({
<div className="flex items-center gap-3">
<BarChart3 className="w-5 h-5 text-blue-500" />
<div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
<h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
<p className="text-sm text-muted-foreground">
{loading ? (
<span className="flex items-center gap-2">
<Search className="w-3 h-3 animate-spin" />
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
{isAnalyzingSelection ? t('chart.analyzingSelection') : t('chart.analyzingNote')} ({wordCount} {t('chart.words')})...
</span>
) : response?.hasData ? (
`Found ${response?.detectedData || 'data'}`
`${t('chart.found')} ${response?.detectedData || ''}`
) : (
'No data detected'
t('chart.noDataDetected')
)}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
@@ -152,7 +157,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
<p className="text-muted-foreground">{t('chart.analyzing')}</p>
</div>
</div>
) : response?.quotaExceeded ? (
@@ -167,7 +172,7 @@ export function ChartSuggestionsDialog({
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')}
>
Upgrade Plan
{t('ai.featureLocked')}
</button>
</div>
</div>
@@ -175,10 +180,10 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h3 className="text-lg font-semibold mb-2">Error</h3>
<h3 className="text-lg font-semibold mb-2">{t('common.error')}</h3>
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
<details className="text-left text-xs text-muted-foreground mt-4">
<summary className="cursor-pointer hover:text-foreground">Debug info</summary>
<summary className="cursor-pointer hover:text-foreground">{t('chart.debugInfo')}</summary>
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
analyzedText: {response.analyzedText}
{'\n'}detectedData: {response.detectedData}
@@ -190,20 +195,20 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
<h3 className="text-lg font-semibold mb-2">{t('chart.noDataDetected')}</h3>
<p className="text-muted-foreground mb-4">
Try adding numerical data to your note. Charts work best with:
{t('chart.noDataHint')}
</p>
<ul className="text-sm text-muted-foreground text-left inline-block">
<li> Sales figures, metrics, or measurements</li>
<li> Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
<li> Percentages or proportions</li>
<li> Time-series data</li>
<li>{t('chart.dataHint1')}</li>
<li>{t('chart.dataHint2')}</li>
<li>{t('chart.dataHint3')}</li>
<li>{t('chart.dataHint4')}</li>
</ul>
<div className="mt-4 pt-4 border-t border-border/50">
<details className="text-left">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Debug: Show what was analyzed
{t('chart.debugShowAnalyzed')}
</summary>
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
{textToAnalyze.substring(0, 500)}
@@ -276,7 +281,7 @@ export function ChartSuggestionsDialog({
{/* Data preview */}
<div className="mt-2 pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground">
{suggestion.data.length} data points
{suggestion.data.length} {t('chart.dataPoints')}
</p>
</div>
</button>
@@ -286,9 +291,9 @@ export function ChartSuggestionsDialog({
{/* Keyboard hint */}
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
<span> Navigate</span>
<span> Select</span>
<span>Esc Cancel</span>
<span>{t('chart.navigateHint')}</span>
<span>{t('chart.selectHint')}</span>
<span>{t('chart.escCancel')}</span>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import ReactFlow, {
Node,
Edge,
@@ -47,6 +48,7 @@ export function ClusterVisualization({
bridgeNotes,
onNodeClick
}: ClusterVisualizationProps) {
const { t } = useLanguage()
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
@@ -79,8 +81,8 @@ export function ClusterVisualization({
data: {
label: (
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
{cluster.name || `Cluster ${cluster.clusterId}`}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
</div>
)
},
@@ -174,8 +176,8 @@ export function ClusterVisualization({
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p>No clusters to display</p>
<p className="text-sm mt-2">Create more notes to generate clusters</p>
<p>{t('clusters.empty')}</p>
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
</div>
</div>
)
@@ -200,10 +202,10 @@ export function ClusterVisualization({
{selectedCluster !== null && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
<h3 className="font-semibold mb-2">
{clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`}
{clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}
</h3>
<p className="text-sm text-gray-600">
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}
</p>
</div>
)}
@@ -212,11 +214,11 @@ export function ClusterVisualization({
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
<span>Bridge note</span>
<span>{t('clusters.bridgeNote')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span>Regular note</span>
<span>{t('clusters.regularNote')}</span>
</div>
</div>
</div>

View File

@@ -229,6 +229,9 @@ export function ContextualAIChat({
const [slideTheme, setSlideTheme] = useState('auto')
const [slideStyle, setSlideStyle] = useState('professional')
const [slideTemplate, setSlideTemplate] = useState('auto')
const [slidePurpose, setSlidePurpose] = useState('auto')
const [slideAudience, setSlideAudience] = useState('auto')
const [slideCount, setSlideCount] = useState('auto')
const [diagramType, setDiagramType] = useState('logic_flow')
const [diagramStyle, setDiagramStyle] = useState('polished')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
@@ -444,6 +447,12 @@ export function ContextualAIChat({
style: type === 'slides' ? slideStyle : diagramStyle,
language: language === 'fr' ? 'French' : 'English',
template: type === 'slides' ? slideTemplate : undefined,
purpose: type === 'slides' ? slidePurpose : undefined,
audience: type === 'slides' ? slideAudience : undefined,
slideCount:
type === 'slides' && slideCount !== 'auto'
? parseInt(slideCount, 10)
: undefined,
}),
})
const data = await res.json()
@@ -1064,13 +1073,13 @@ export function ContextualAIChat({
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
@@ -1105,16 +1114,66 @@ export function ContextualAIChat({
</select>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.templateAuto')}</option>
<option value="board-update">{t('ai.generate.templateBoard')}</option>
<option value="project-status">{t('ai.generate.templateProject')}</option>
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
</select>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.purpose') || 'Type de deck'}</span>
<select value={slidePurpose} onChange={e => setSlidePurpose(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.purposeAuto') || 'Auto (selon la note)'}</option>
<option value="course">{t('ai.generate.purposeCourse') || 'Cours / leçon'}</option>
<option value="board">{t('ai.generate.purposeBoard') || 'Comité / board'}</option>
<option value="project">{t('ai.generate.purposeProject') || 'Suivi projet'}</option>
<option value="strategy">{t('ai.generate.purposeStrategy') || 'Stratégie'}</option>
<option value="pitch">{t('ai.generate.purposePitch') || 'Pitch'}</option>
<option value="summary">{t('ai.generate.purposeSummary') || 'Synthèse courte'}</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.audience') || 'Audience'}</span>
<select value={slideAudience} onChange={e => setSlideAudience(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.audienceAuto') || 'Auto'}</option>
<option value="student">{t('ai.generate.audienceStudent') || 'Étudiants'}</option>
<option value="board">{t('ai.generate.audienceBoard') || 'Direction'}</option>
<option value="team">{t('ai.generate.audienceTeam') || 'Équipe'}</option>
<option value="client">{t('ai.generate.audienceClient') || 'Client'}</option>
<option value="general">{t('ai.generate.audienceGeneral') || 'Grand public'}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.slideCount') || 'Nb de slides'}</span>
<select value={slideCount} onChange={e => setSlideCount(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.slideCountAuto') || 'Auto (selon longueur)'}</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.templateAuto')}</option>
<option value="board-update">{t('ai.generate.templateBoard')}</option>
<option value="project-status">{t('ai.generate.templateProject')}</option>
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
</select>
</div>
</div>
{(() => {
const n = slideCount === 'auto' ? 6 : Math.min(8, Math.max(3, parseInt(slideCount, 10) || 6))
const creditCost = 1 + n
return (
<p className="text-[10px] text-muted-foreground px-0.5 leading-relaxed">
{t('ai.generate.creditCostEstimate', {
cost: String(creditCost),
slides: String(n),
})}
</p>
)
})()}
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
</button>
@@ -1170,13 +1229,13 @@ export function ContextualAIChat({
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
@@ -1280,7 +1339,7 @@ export function ContextualAIChat({
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
].map((mode) => (
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-card dark:bg-white/5 border-border hover:bg-slate-50 dark:hover:bg-white/10')}>
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>

View File

@@ -0,0 +1,126 @@
'use client'
import { motion } from 'motion/react'
import { Inbox, GraduationCap, Bell, Sparkles, Layers } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface DashboardActionStripProps {
inboxCount: number
dueFlashcards: number
reminderCount: number
discoveryCount: number
themeCount: number
inboxPulse?: number
onInbox: () => void
onReview: () => void
onReminders: () => void
onDiscoveries: () => void
onThemes: () => void
prefersReducedMotion?: boolean
}
export function DashboardActionStrip({
inboxCount,
dueFlashcards,
reminderCount,
discoveryCount,
themeCount,
inboxPulse = 0,
onInbox,
onReview,
onReminders,
onDiscoveries,
onThemes,
prefersReducedMotion,
}: DashboardActionStripProps) {
const { t } = useLanguage()
const items = [
{
key: 'inbox',
icon: Inbox,
label: t('homeDashboard.inbox'),
value: inboxCount,
onClick: onInbox,
accent: inboxCount > 0,
pulse: inboxPulse > 0,
},
{
key: 'review',
icon: GraduationCap,
label: t('homeDashboard.review'),
value: dueFlashcards,
onClick: onReview,
accent: dueFlashcards > 0,
},
{
key: 'reminders',
icon: Bell,
label: t('homeDashboard.reminders'),
value: reminderCount,
onClick: onReminders,
accent: reminderCount > 0,
},
{
key: 'discoveries',
icon: Sparkles,
label: t('homeDashboard.aiFound'),
value: discoveryCount,
onClick: onDiscoveries,
accent: discoveryCount > 0,
},
{
key: 'themes',
icon: Layers,
label: t('homeDashboard.themes'),
value: themeCount,
onClick: onThemes,
accent: themeCount > 0,
},
]
return (
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-0.5 -mx-0.5 px-0.5">
{items.map(item => {
const Icon = item.icon
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion
? {
key: `inbox-pulse-${inboxPulse}`,
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.key}
type="button"
onClick={item.onClick}
{...motionProps}
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
}`}>
<Icon size={13} strokeWidth={2} />
</div>
<div className="min-w-0">
<p className={`text-lg font-serif font-bold leading-none ${
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
}`}>
{item.value}
</p>
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
{item.label}
</p>
</div>
</Wrapper>
)
})}
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
export interface AgentSuggestion {
id: string
topic: string
reason: string
relatedNoteCount: number
suggestedFrequency: string
}
export interface DashboardAgentCarouselProps {
suggestions: AgentSuggestion[]
loading?: boolean
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardAgentCarousel({
suggestions,
loading,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const navActions = suggestions.length > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
disabled={idx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={12} />
</button>
<span className="text-[8px] font-mono text-concrete px-1">
{idx + 1}/{suggestions.length}
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
disabled={idx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={12} />
</button>
</div>
) : null
if (loading) {
return <div className="h-[140px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="agents"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.suggestedResearch')}
actions={navActions}
/>
{suggestions.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] text-concrete leading-relaxed">
{t('homeDashboard.agentsEmpty')}
</p>
</div>
) : (
<AgentSlide
current={suggestions[idx]}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
onDismiss={onDismiss}
prefersReducedMotion={prefersReducedMotion}
t={t}
/>
)}
</div>
)
}
function AgentSlide({
current,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
t,
}: {
current: AgentSuggestion
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
t: (key: string) => string
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
return (
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={slide.initial}
animate={slide.animate}
exit={slide.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}
className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40"
>
<div className="flex items-start gap-2 mb-2">
<Search size={12} className="text-brand-accent shrink-0 mt-0.5" />
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">{current.topic}</p>
</div>
<p className="text-[11px] text-concrete leading-relaxed line-clamp-2 mb-3">{current.reason}</p>
<p className="text-[8px] font-mono uppercase text-concrete mb-3">
{current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)}
</p>
<div className="flex gap-2">
<button
type="button"
disabled={actingId === current.id}
onClick={() => onDismiss(current.id)}
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
<button
type="button"
disabled={actingId === current.id}
onClick={() => onAccept(current.id)}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
>
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
{t('homeDashboard.createAgent')}
</button>
</div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,413 @@
'use client'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
import { UsageMeter } from '@/components/usage-meter'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetShellProps {
widgetId: DashboardWidgetId
icon: React.ReactNode
title: string
children: React.ReactNode
compact?: boolean
headerActions?: React.ReactNode
}
export function DashboardWidgetShell({
widgetId,
icon,
title,
children,
compact,
headerActions,
}: DashboardWidgetShellProps) {
return (
<div className={`rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 h-full ${
compact ? 'p-3' : 'p-4'
}`}>
<DashboardWidgetTitleRow
widgetId={widgetId}
icon={icon}
title={title}
actions={headerActions}
className={compact ? 'mb-2' : 'mb-3'}
/>
{children}
</div>
)
}
export function DashboardInboxWidget({
count,
loading,
onOpen,
}: {
count: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="inbox"
icon={<Inbox size={12} className="text-brand-accent" />}
title={t('homeDashboard.inbox')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardRevisionWidget({
dueCount,
loading,
onOpen,
}: {
dueCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="revision"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.review')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardStatsWidget({
clusterCount,
bridgeCount,
noteCount,
loading,
onOpen,
}: {
clusterCount: number
bridgeCount: number
noteCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="stats"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.stats')}
compact
>
{loading ? (
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
<div className="grid grid-cols-3 gap-2">
{[
{ label: t('homeDashboard.themes'), value: clusterCount },
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
].map(item => (
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
</div>
))}
</div>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardAgentActivityWidget({
actions,
loading,
onOpen,
}: {
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="agent-activity"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.agent-activity')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : actions.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
) : (
<div className="space-y-1.5">
{actions.slice(0, 3).map(a => (
<button
key={a.id}
type="button"
onClick={onOpen}
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
>
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
</p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardGmailWidget({
connected,
recentCaptures,
loading,
onOpen,
}: {
connected: boolean
recentCaptures: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="gmail"
icon={<Mail size={12} className="text-brand-accent" />}
title={t('homeDashboard.gmailCaptures')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : connected ? (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
>
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
</button>
) : (
<button
type="button"
onClick={onOpen}
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
>
{t('homeDashboard.gmailConnect')}
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardPinnedWidget({
notes,
loading,
onSelect,
}: {
notes: { id: string; title: string | null; notebookId: string | null }[]
loading: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="pinned"
icon={<Pin size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.pinned')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
) : (
<div className="space-y-1">
{notes.map(n => (
<button
key={n.id}
type="button"
onClick={() => onSelect(n.id, n.notebookId)}
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
>
{n.title || t('homeDashboard.untitled')}
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardActivityWidget({
data,
loading,
}: {
data: { date: string; count: number }[]
loading: boolean
}) {
const { t } = useLanguage()
const hasActivity = data.some(d => d.count > 0)
return (
<DashboardWidgetShell
widgetId="activity"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.activity')}
>
{loading ? (
<div className="h-24 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : !hasActivity ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-4 text-center">
<p className="text-[10px] text-concrete leading-relaxed">{t('homeDashboard.activityEmptyHint')}</p>
</div>
) : (
<>
<RevisionHeatmap data={data} />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</>
)}
</DashboardWidgetShell>
)
}
export function DashboardUsageWidget() {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="usage"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.usage')}
compact
>
<UsageMeter className="px-0 py-0" />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardFlashcardsProgressWidget({
retentionRate,
streak,
totalCards,
dueCount = 0,
loading,
onOpen,
}: {
retentionRate: number
streak: number
totalCards: number
dueCount?: number
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
const retention = Math.max(0, Math.min(100, retentionRate))
return (
<DashboardWidgetShell
widgetId="flashcards-progress"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.flashcards-progress')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-12 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>
) : totalCards === 0 && dueCount === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3 text-center">
<p className="text-[10px] text-concrete leading-relaxed mb-2">
{t('homeDashboard.flashEmptyHint')}
</p>
<button
type="button"
onClick={onOpen}
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
{t('homeDashboard.flashEmptyCta')}
</button>
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start group">
<div className="grid grid-cols-3 gap-2 mb-2.5">
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retention}%</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards || dueCount}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
</div>
</div>
<div className="h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden mb-2">
<div
className="h-full rounded-full bg-brand-accent transition-all"
style={{ width: `${retention}%` }}
/>
</div>
{dueCount > 0 ? (
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
{t('homeDashboard.flashDueCta', { count: dueCount })}
</p>
) : (
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
{t('homeDashboard.flashOpenCta')}
</p>
)}
</button>
)}
</DashboardWidgetShell>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { motion } from 'motion/react'
import { Layers, Zap, ArrowUpRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface Cluster {
clusterId: number
name?: string
noteIds: string[]
}
interface BridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null }
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardMindOrbit({
clusters,
bridgeNotes,
loading,
onOpenInsights,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
const { t } = useLanguage()
const topClusters = [...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
if (topClusters.length === 0) {
return (
<button
type="button"
onClick={onOpenInsights}
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
>
<Layers size={22} className="text-concrete/35" />
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
</button>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="mind-map"
icon={<Layers size={12} className="text-brand-accent" />}
title={t('homeDashboard.mindMap')}
actions={(
<button
type="button"
onClick={onOpenInsights}
className="inline-flex items-center gap-0.5 text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline"
>
{t('homeDashboard.fullMap')}
<ArrowUpRight size={10} />
</button>
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
>
<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"
style={{
width: size,
height: size,
backgroundColor: `${color}cc`,
borderColor: `${color}40`,
fontSize: Math.max(9, size * 0.22),
}}
>
{cluster.noteIds.length}
</div>
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
{label}
</span>
</motion.button>
)
})}
</div>
{topBridge?.note && (
<button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{topBridge.note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.bridgeNote')}</p>
</div>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import { motion } from 'motion/react'
import { Loader2 } from 'lucide-react'
import {
ArrowRight, GitBranch, Lightbulb, Link2, BookOpen, Inbox,
GraduationCap, Compass, Plus, Sparkles, PenLine,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
continue: { Icon: PenLine, accent: 'text-ink' },
connect: { Icon: Link2, accent: 'text-brand-accent' },
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
research: { Icon: Sparkles, accent: 'text-sky-600' },
explore: { Icon: Compass, accent: 'text-amber-600' },
organize: { Icon: Inbox, accent: 'text-brand-accent' },
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
daily: { Icon: BookOpen, accent: 'text-concrete' },
}
export interface DashboardNextPathsProps {
paths: DashboardPath[]
loading?: boolean
enriching?: boolean
focusNoteTitle?: string | null
onAction: (path: DashboardPath) => void
prefersReducedMotion?: boolean
}
export function DashboardNextPaths({
paths,
loading,
enriching,
focusNoteTitle,
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
if (loading) {
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="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3 px-5 py-10">
<Loader2 size={22} className="text-brand-accent animate-spin" strokeWidth={2} />
<p className="text-[11px] text-concrete text-center leading-relaxed max-w-[240px]">
{t('homeDashboard.pathsLoading')}
</p>
</div>
</div>
)
}
if (paths.length === 0) {
return (
<div className="rounded-2xl border border-border/25 bg-white/60 dark:bg-zinc-900/60 p-5">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-2"
/>
<p className="text-[10px] text-concrete italic">{t('homeDashboard.pathsEmpty')}</p>
</div>
)
}
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
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="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
actions={enriching ? (
<span className="inline-flex items-center gap-1.5 text-[8px] font-mono uppercase text-concrete">
<Loader2 size={10} className="text-brand-accent animate-spin" />
{t('homeDashboard.pathsEnriching')}
</span>
) : undefined}
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
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={`w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center shrink-0 ${TYPE_META[hero.type].accent}`}>
<HeroIcon size={16} strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete mb-0.5">
{t(`homeDashboard.pathTypes.${hero.type}`)}
{hero.score ? ` · ${hero.score}%` : ''}
</p>
<p className="text-sm font-serif font-semibold text-ink dark:text-dark-ink leading-snug">
{hero.title}
</p>
<p className="text-[11px] text-concrete leading-relaxed mt-1 line-clamp-2">
{hero.description}
</p>
</div>
<span className="shrink-0 inline-flex items-center gap-1 text-[9px] font-mono font-bold uppercase text-brand-accent mt-1">
{t(`homeDashboard.pathActions.${hero.actionKey}`)}
<ArrowRight size={10} />
</span>
</div>
</motion.button>
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
key={path.id}
type="button"
onClick={() => onAction(path)}
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}`} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-ink dark:text-dark-ink truncate">{path.title}</p>
<p className="text-[9px] text-concrete truncate">{path.description}</p>
</div>
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetShell } from '@/components/dashboard-catalog-widgets'
export interface DailyReviewItem {
key: string
label: string
done: boolean
count?: number
onClick: () => void
}
export function DashboardDailyReview({
items,
loading,
}: {
items: DailyReviewItem[]
loading?: boolean
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-review"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-review')}
compact
>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<ul className="space-y-1.5">
{items.map(item => (
<li key={item.key}>
<button
type="button"
onClick={item.onClick}
className="w-full flex items-center gap-2.5 p-2 rounded-xl hover:bg-brand-accent/[0.04] transition-colors text-start"
>
{item.done ? (
<CheckCircle2 size={14} className="text-brand-accent shrink-0" />
) : (
<Circle size={14} className="text-concrete/50 shrink-0" />
)}
<span className={`text-[10px] flex-1 ${item.done ? 'text-concrete line-through' : 'text-ink dark:text-dark-ink'}`}>
{item.label}
{item.count !== undefined && item.count > 0 ? ` (${item.count})` : ''}
</span>
</button>
</li>
))}
</ul>
)}
<p className="text-[9px] text-concrete mt-2 leading-relaxed">{t('homeDashboard.dailyReviewHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardOpenLoops({
loops,
loading,
onSelect,
}: {
loops: Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="open-loops"
icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.open-loops')}
compact
>
{loading ? (
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
) : loops.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
) : (
<div className="space-y-1">
{loops.map(loop => (
<button
key={loop.id}
type="button"
onClick={() => onSelect(loop.id, loop.notebookId)}
className="w-full flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-brand-accent/[0.04] text-start transition-colors"
>
<span className="text-[10px] text-ink dark:text-dark-ink truncate flex-1">
{loop.title || t('homeDashboard.untitled')}
</span>
<span className="text-[8px] font-mono text-concrete shrink-0">
{t('homeDashboard.openLoopsStale', { days: loop.daysStale })}
</span>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardDailyNoteWidget({
loading,
onOpen,
}: {
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-note"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-note')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
>
<p className="text-[11px] font-serif font-semibold text-ink dark:text-dark-ink">
{new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<p className="text-[9px] font-mono uppercase text-brand-accent mt-1">{t('homeDashboard.dailyNoteOpen')} </p>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardLinkSuggestions({
paths,
loading,
onAction,
}: {
paths: Array<{ id: string; title: string; description: string; score?: number }>
loading?: boolean
onAction: (id: string) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="link-suggestions"
icon={<Circle size={12} className="text-emerald-600" />}
title={t('homeDashboard.widgets.link-suggestions')}
>
{loading ? (
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : paths.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
) : (
<div className="space-y-2">
{paths.map(p => (
<button
key={p.id}
type="button"
onClick={() => onAction(p.id)}
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
{p.score ? (
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
) : null}
</div>
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} </p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardBridgesWidget({
suggestions,
loading,
actingKey,
onCreate,
onDismiss,
}: {
suggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
justification: string
}>
loading?: boolean
actingKey?: string | null
onCreate: (s: { clusterAId: number; clusterBId: number; clusterAName: string; clusterBName: string; suggestedTitle: string; suggestedContent: string; justification: string }) => void
onDismiss: (s: { clusterAId: number; clusterBId: number }) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="bridges"
icon={<Circle size={12} className="text-violet-600" />}
title={t('homeDashboard.widgets.bridges')}
>
{loading ? (
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
) : suggestions.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
) : (
<div className="space-y-2">
{suggestions.slice(0, 3).map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
return (
<div key={key} className="p-3 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30">
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink">{s.suggestedTitle}</p>
<p className="text-[9px] text-concrete mt-1">
{t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })}
</p>
<p className="text-[10px] text-concrete mt-1 line-clamp-2">{s.justification}</p>
<div className="flex gap-2 mt-2">
<button
type="button"
disabled={actingKey === key}
onClick={() => onCreate({ ...s, suggestedContent: s.justification })}
className="text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline disabled:opacity-50"
>
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={actingKey === key}
onClick={() => onDismiss(s)}
className="text-[8px] font-mono uppercase text-concrete hover:text-rose-500 disabled:opacity-50"
>
{t('homeDashboard.dismissConnection')}
</button>
</div>
</div>
)
})}
</div>
)}
</DashboardWidgetShell>
)
}

View File

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

View File

@@ -0,0 +1,113 @@
'use client'
import { Heart } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface EmotionMeta {
Icon: LucideIcon
color: string
}
export interface DashboardSentimentChipProps {
available: boolean
loading?: boolean
dominantEmotion?: string
summary?: string
emotions?: Record<string, number>
emotionMeta: Record<string, EmotionMeta>
}
export function DashboardSentimentChip({
available,
loading,
dominantEmotion,
summary,
emotions,
emotionMeta,
}: DashboardSentimentChipProps) {
const { t } = useLanguage()
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<div className="h-24 rounded-xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4 min-h-[140px]">
<DashboardWidgetTitleRow
widgetId="sentiment"
icon={<Heart size={12} className="text-brand-accent" />}
title={t('homeDashboard.sentiment')}
/>
{!available || !dominantEmotion ? (
<p className="text-[11px] text-concrete italic leading-relaxed">
{t('homeDashboard.notEnoughNotes')}
</p>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
{(() => {
const meta = emotionMeta[dominantEmotion] || emotionMeta.reflective
const Icon = meta?.Icon
return (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${meta.color}18`, color: meta.color }}
>
{Icon && <Icon size={18} strokeWidth={1.75} />}
</div>
)
})()}
<div className="min-w-0">
<p className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">
{t(`homeDashboard.emotions.${dominantEmotion}`)}
</p>
<p className="text-[9px] font-mono uppercase tracking-wider text-concrete">
{t('homeDashboard.sentimentDominant')}
</p>
</div>
</div>
{summary && (
<p className="text-[11px] text-concrete leading-relaxed border-s-2 border-brand-accent/30 ps-3">
{summary}
</p>
)}
{emotions && (
<div className="space-y-2 pt-1">
{Object.entries(emotions)
.sort(([, a], [, b]) => b - a)
.slice(0, 4)
.map(([key, val]) => {
const em = emotionMeta[key]
return (
<div key={key} className="flex items-center gap-2">
<span className="text-[8px] font-mono uppercase text-concrete w-16 truncate">
{t(`homeDashboard.emotions.${key}`)}
</span>
<div className="flex-1 h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(val * 100, 100)}%`,
backgroundColor: em?.color || 'var(--color-brand-accent)',
}}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,415 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
rectSortingStrategy,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Plus, RotateCcw, Check, LayoutGrid, Eye, EyeOff } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import {
getDefaultDashboardLayout,
resetDashboardLayout,
DASHBOARD_CATEGORY_ORDER,
DASHBOARD_WIDGET_META,
catalogByCategory,
hiddenWidgetIds,
isWidgetVisible,
normalizeDashboardLayout,
reorderWidgets,
setWidgetVisibility,
visibleWidgetsInZone,
type DashboardLayout,
type DashboardWidgetId,
type DashboardWidgetPlacement,
type DashboardWidgetZone,
} from '@/lib/dashboard/layout'
import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}
function SortableWidget({
placement,
editMode,
onHide,
children,
}: {
placement: DashboardWidgetPlacement
editMode: boolean
onHide: (id: DashboardWidgetId) => void
children: React.ReactNode
}) {
const { t } = useLanguage()
const meta = DASHBOARD_WIDGET_META[placement.id]
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: placement.id, disabled: !editMode })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 20 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`relative w-full ${
isDragging ? 'opacity-90 scale-[1.01]' : ''
} ${editMode ? 'ring-2 ring-brand-accent/25 ring-offset-2 ring-offset-[#F9F8F6] dark:ring-offset-[#0D0D0D] rounded-2xl' : ''}`}
>
{editMode && (
<div className="absolute top-2 end-2 z-20 flex items-center gap-1">
<button
type="button"
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm cursor-grab active:cursor-grabbing text-concrete hover:text-ink"
aria-label={t('homeDashboard.widgetDrag')}
{...attributes}
{...listeners}
>
<GripVertical size={12} />
</button>
{!meta.required && (
<button
type="button"
onClick={() => onHide(placement.id)}
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm text-concrete hover:text-rose-500"
aria-label={t('homeDashboard.widgetHide')}
>
<X size={12} />
</button>
)}
</div>
)}
{children}
</div>
)
}
function ZoneColumn({
zone,
placements,
editMode,
onHide,
renderWidget,
}: {
zone: DashboardWidgetZone
placements: DashboardWidgetPlacement[]
editMode: boolean
onHide: (id: DashboardWidgetId) => void
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}) {
if (placements.length === 0) return null
return (
<SortableContext items={placements.map(w => w.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{placements.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={onHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
const [catalogOpen, setCatalogOpen] = useState(false)
const [loaded, setLoaded] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/dashboard/layout', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json?.layout) {
const normalized = normalizeDashboardLayout(json.layout)
setLayout(normalized)
const incomingVersion = typeof (json.layout as { version?: number })?.version === 'number'
? (json.layout as { version: number }).version
: 0
if (incomingVersion < normalized.version) {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: normalized }),
}).catch(() => {})
}
}
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
const persistLayout = useCallback((next: DashboardLayout, immediate = false) => {
if (saveTimer.current) clearTimeout(saveTimer.current)
const save = () => {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: next }),
}).catch(() => {})
}
if (immediate) save()
else saveTimer.current = setTimeout(save, 600)
}, [])
const applyLayout = useCallback((next: DashboardLayout, immediate = false) => {
const normalized = normalizeDashboardLayout(next)
setLayout(normalized)
persistLayout(normalized, immediate)
}, [persistLayout])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
applyLayout(reorderWidgets(layout, active.id as DashboardWidgetId, over.id as DashboardWidgetId))
}, [applyLayout, layout])
const handleHide = useCallback((id: DashboardWidgetId) => {
applyLayout(setWidgetVisibility(layout, id, false))
}, [applyLayout, layout])
const handleToggle = useCallback((id: DashboardWidgetId) => {
const visible = isWidgetVisible(layout, id)
if (visible) {
applyLayout(setWidgetVisibility(layout, id, false))
} else {
const maxOrder = Math.max(...layout.widgets.map(w => w.order), 0)
applyLayout({
...layout,
widgets: layout.widgets.map(w =>
w.id === id ? { ...w, visible: true, order: maxOrder + 1 } : w,
),
})
}
}, [applyLayout, layout])
const handleReset = useCallback(() => {
const fresh = resetDashboardLayout()
setLayout(fresh)
persistLayout(fresh, true)
setCatalogOpen(false)
toast.success(t('homeDashboard.widgetResetDone'))
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
const mainWidgets = visibleWidgetsInZone(layout, 'main')
const sideWidgets = visibleWidgetsInZone(layout, 'side')
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
return (
<>
<div className="space-y-4">
{loaded ? (
hasVisibleWidgets ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{fullWidgets.length > 0 && (
<SortableContext items={fullWidgets.map(w => w.id)} strategy={rectSortingStrategy}>
<div className="flex flex-col gap-4">
{fullWidgets.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={handleHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)}
{(mainWidgets.length > 0 || sideWidgets.length > 0) && (
<div className="grid grid-cols-12 gap-4 items-start">
{mainWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-8">
<ZoneColumn
zone="main"
placements={mainWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
{sideWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-4">
<ZoneColumn
zone="side"
placements={sideWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
</div>
)}
</DndContext>
) : (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/60 dark:bg-zinc-900/60 p-8 text-center">
<p className="text-sm font-serif text-ink dark:text-dark-ink mb-2">{t('homeDashboard.layoutEmptyTitle')}</p>
<p className="text-[11px] text-concrete mb-4 max-w-md mx-auto">{t('homeDashboard.layoutEmptyHint')}</p>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-accent text-white text-[10px] font-mono uppercase font-bold hover:bg-brand-accent/90 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
</div>
)
) : (
<div className="grid grid-cols-12 gap-5">
<div className="col-span-12 h-20 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-8 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-4 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)}
</div>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
{editMode ? (
<>
<button
type="button"
onClick={() => setCatalogOpen(v => !v)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<Plus size={12} />
{t('homeDashboard.widgetCatalog')}
{hidden.length > 0 && (
<span className="px-1.5 py-0.5 rounded-md bg-brand-accent/90 text-[9px]">{hidden.length}</span>
)}
</button>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
<button
type="button"
onClick={() => { setEditMode(false); setCatalogOpen(false) }}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
>
<Check size={12} />
{t('homeDashboard.widgetDone')}
</button>
</>
) : (
<button
type="button"
onClick={() => setEditMode(true)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<LayoutGrid size={12} />
{t('homeDashboard.widgetCustomize')}
</button>
)}
</div>
{editMode && catalogOpen && (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
<p className="text-[9px] font-mono font-bold uppercase tracking-wider text-concrete mb-3">
{t('homeDashboard.widgetCatalogTitle')}
</p>
<div className="space-y-4">
{DASHBOARD_CATEGORY_ORDER.map(category => {
const ids = catalog[category]
if (ids.length === 0) return null
return (
<div key={category}>
<p className="text-[8px] font-mono font-bold uppercase tracking-[0.15em] text-brand-accent mb-2">
{t(`homeDashboard.widgetCategories.${category}`)}
</p>
<div className="space-y-1.5">
{ids.map(id => {
const meta = DASHBOARD_WIDGET_META[id]
const active = isWidgetVisible(layout, id)
return (
<button
key={id}
type="button"
onClick={() => !meta.required && handleToggle(id)}
disabled={meta.required}
className={`w-full flex items-start gap-3 p-2.5 rounded-xl border text-start transition-colors ${
active
? 'border-brand-accent/30 bg-brand-accent/[0.04]'
: 'border-border/25 bg-stone-50/80 dark:bg-zinc-950/40 hover:border-brand-accent/25'
} ${meta.required ? 'opacity-80 cursor-default' : 'cursor-pointer'}`}
>
<div className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center ${
active ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-200/60 dark:bg-zinc-800 text-concrete'
}`}>
{active ? <Eye size={11} /> : <EyeOff size={11} />}
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-mono font-bold uppercase tracking-wide text-ink dark:text-dark-ink">
{t(`homeDashboard.widgets.${id}`)}
</p>
<p className="text-[10px] text-concrete leading-snug mt-0.5">
{t(`homeDashboard.widgetDescriptions.${id}`)}
</p>
</div>
{!meta.required && (
<span className={`shrink-0 text-[9px] font-mono font-bold uppercase px-2 py-1 rounded-lg ${
active ? 'text-concrete' : 'text-brand-accent bg-brand-accent/10'
}`}>
{active ? t('homeDashboard.widgetActive') : t('homeDashboard.widgetAddShort')}
</span>
)}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { useState } from 'react'
import { HelpCircle, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetHelpProps {
widgetId: DashboardWidgetId
className?: string
}
export function DashboardWidgetHelp({ widgetId, className = '' }: DashboardWidgetHelpProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const helpKey = `homeDashboard.widgetHelp.${widgetId}`
const text = t(helpKey)
if (text === helpKey) return null
return (
<div className={`relative ${className}`}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={`p-1 rounded-full border transition-colors ${
open
? 'border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
: 'border-border/30 bg-white/90 dark:bg-zinc-900/90 text-concrete hover:text-brand-accent hover:border-brand-accent/30'
}`}
aria-label={t('homeDashboard.widgetHelpLabel')}
aria-expanded={open}
>
<HelpCircle size={12} strokeWidth={2} />
</button>
{open && (
<div className="absolute top-full end-0 mt-1.5 z-30 w-[min(280px,calc(100vw-3rem))] p-3 rounded-xl border border-brand-accent/20 bg-white dark:bg-zinc-900 shadow-lg">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-brand-accent">
{t(`homeDashboard.widgets.${widgetId}`)}
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="text-concrete hover:text-ink shrink-0"
aria-label={t('homeDashboard.widgetHelpClose')}
>
<X size={10} />
</button>
</div>
<p className="text-[10px] text-concrete leading-relaxed">{text}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import type { ReactNode } from 'react'
import { DashboardWidgetHelp } from '@/components/dashboard-widget-help'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetTitleRowProps {
widgetId: DashboardWidgetId
icon?: ReactNode
title: string
actions?: ReactNode
className?: string
}
/** Titre widget : actions dabord, aide « ? » en dernier — ne masque jamais la navigation. */
export function DashboardWidgetTitleRow({
widgetId,
icon,
title,
actions,
className = 'mb-3',
}: DashboardWidgetTitleRowProps) {
return (
<div className={`flex items-center justify-between gap-2 ${className}`}>
<div className="flex items-center gap-2 min-w-0 flex-1">
{icon}
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
{title}
</h2>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{actions}
<DashboardWidgetHelp widgetId={widgetId} />
</div>
</div>
)
}

View File

@@ -34,6 +34,7 @@ import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -58,6 +59,10 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false }
)
const DashboardView = dynamic(
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
@@ -225,15 +230,12 @@ export function HomeClient({
}, [])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
// On GARDE forceList dans l'URL pour distinguer "liste" du "dashboard"
useEffect(() => {
if (searchParams.get('forceList') === '1') {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
router.replace(newUrl, { scroll: false })
}
}, [searchParams, router])
}, [searchParams])
const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
@@ -619,6 +621,11 @@ export function HomeClient({
if (detail.type === 'updated') {
patchNoteInList(detail.note.id, detail.note)
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
} else if (detail.type === 'deleted') {
removeNoteFromList(detail.noteId)
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
} else if (detail.type === 'created' && detail.note) {
setNotes(prev => [detail.note, ...prev])
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
@@ -816,6 +823,16 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: savedNote })
}, [])
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
router.push(`/home?${params.toString()}`)
}, [router])
return (
<div
className={cn(
@@ -833,6 +850,8 @@ export function HomeClient({
fullPage
/>
</div>
) : showDashboard ? (
<DashboardView onNoteSelect={handleDashboardNoteSelect} />
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div

View File

@@ -0,0 +1,729 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles, Zap, Lightbulb, Bot, ChevronLeft, ChevronRight,
ExternalLink, GitCompare, X, RefreshCw, Loader2, Link2, Brain, ArrowRight,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
// ─── Types ─────────────────────────────────────────────
export interface IntelBriefingInsight {
id: string
insight: string
score: number
date: string
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}
export interface IntelBridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null; content?: string }
}
export interface IntelBridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
export interface IntelAgentAction {
id: string
agentName: string
result: string | null
createdAt: string
}
type IntelFilter = 'all' | 'connections' | 'bridges' | 'ideas' | 'agents'
type IntelItem =
| { kind: 'insight'; id: string; priority: number; insight: IntelBriefingInsight }
| { kind: 'bridge'; id: string; priority: number; bridge: IntelBridgeNote }
| { kind: 'suggestion'; id: string; priority: number; suggestion: IntelBridgeSuggestion }
| { kind: 'agent'; id: string; priority: number; agent: IntelAgentAction }
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const FILTER_KINDS: Record<IntelFilter, IntelItem['kind'][] | null> = {
all: null,
connections: ['insight'],
bridges: ['bridge'],
ideas: ['suggestion'],
agents: ['agent'],
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
}
function formatRelativeTime(
dateStr: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return t('time.justNow')
if (mins < 60) return t('time.minutesAgo', { count: mins })
if (hours < 24) return t('time.hoursAgo', { count: hours })
return t('time.daysAgo', { count: days })
}
// ─── Visual: lien entre deux notes ─────────────────────
function ConnectionDiagram({
note1Title,
note2Title,
score,
color = '#6366F1',
}: {
note1Title: string
note2Title: string
score: number
color?: string
}) {
return (
<div className="flex items-center gap-2 py-3">
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note1Title}
</p>
</div>
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
<span
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
>
{Math.round(score * 100)}%
</span>
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
</div>
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note2Title}
</p>
</div>
</div>
)
}
// ─── Props ─────────────────────────────────────────────
export interface IntelligenceHubProps {
loading: boolean
aiActive: boolean
hasAiConsent: boolean
providerReady: boolean
memoryEchoEnabled: boolean
insights: IntelBriefingInsight[]
bridgeNotes: IntelBridgeNote[]
bridgeSuggestions: IntelBridgeSuggestion[]
agentActions: IntelAgentAction[]
onNoteSelect: (noteId: string) => void
onRefreshEcho: () => void
onEnableAi: () => void
echoRefreshing: boolean
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
}
// ─── Component ─────────────────────────────────────────
export function IntelligenceHub({
loading,
aiActive,
hasAiConsent,
providerReady,
memoryEchoEnabled,
insights,
bridgeNotes,
bridgeSuggestions,
agentActions,
onNoteSelect,
onRefreshEcho,
onEnableAi,
echoRefreshing,
onDismissInsight,
onDismissBridgeSuggestion,
onCreateBridgeSuggestion,
onOpenInsightNote,
dismissingInsightId,
actingBridgeSuggestionKey,
prefersReducedMotion,
}: IntelligenceHubProps) {
const router = useRouter()
const { t } = useLanguage()
const [filter, setFilter] = useState<IntelFilter>('all')
const [activeIndex, setActiveIndex] = useState(0)
const localizeInsightText = useCallback((insight: IntelBriefingInsight) => {
const text = insight.insight.trim()
if (MEMORY_ECHO_LEGACY_EN_FALLBACKS.has(text)) {
if (insight.note1Excerpt && insight.note2Excerpt) {
return t('homeDashboard.connectionBetween', {
note1: insight.note1.title || insight.note1Excerpt.slice(0, 50),
note2: insight.note2.title || insight.note2Excerpt.slice(0, 50),
})
}
return t('memoryEcho.defaultInsight')
}
return text
}, [t])
const allItems = useMemo(() => {
const insightNoteIds = new Set<string>()
for (const i of insights) {
insightNoteIds.add(i.note1.id)
insightNoteIds.add(i.note2.id)
}
const items: IntelItem[] = []
for (const insight of insights) {
items.push({
kind: 'insight',
id: `insight-${insight.id}`,
priority: (insight.viewed ? 40 : 100) + insight.score * 10,
insight,
})
}
for (const bridge of bridgeNotes) {
if (insightNoteIds.has(bridge.noteId)) continue
items.push({
kind: 'bridge',
id: `bridge-${bridge.noteId}`,
priority: 60 + bridge.bridgeScore * 10,
bridge,
})
}
for (const suggestion of bridgeSuggestions) {
items.push({
kind: 'suggestion',
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
priority: 80,
suggestion,
})
}
for (const agent of agentActions) {
items.push({
kind: 'agent',
id: `agent-${agent.id}`,
priority: 30,
agent,
})
}
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
const counts = useMemo(() => ({
all: allItems.length,
connections: allItems.filter(i => i.kind === 'insight').length,
bridges: allItems.filter(i => i.kind === 'bridge').length,
ideas: allItems.filter(i => i.kind === 'suggestion').length,
agents: allItems.filter(i => i.kind === 'agent').length,
}), [allItems])
const filteredItems = useMemo(() => {
const kinds = FILTER_KINDS[filter]
if (!kinds) return allItems
return allItems.filter(i => kinds.includes(i.kind))
}, [allItems, filter])
useEffect(() => {
setActiveIndex(0)
}, [filter])
useEffect(() => {
if (activeIndex >= filteredItems.length && filteredItems.length > 0) {
setActiveIndex(filteredItems.length - 1)
}
}, [activeIndex, filteredItems.length])
const activeItem = filteredItems[activeIndex] ?? null
const newCount = insights.filter(i => !i.viewed).length
const goPrev = () => setActiveIndex(i => Math.max(0, i - 1))
const goNext = () => setActiveIndex(i => Math.min(filteredItems.length - 1, i + 1))
const filters: { key: IntelFilter; label: string }[] = [
{ key: 'all', label: t('homeDashboard.intelFilterAll') },
{ key: 'connections', label: t('homeDashboard.intelFilterConnections') },
{ key: 'bridges', label: t('homeDashboard.intelFilterBridges') },
{ key: 'ideas', label: t('homeDashboard.intelFilterIdeas') },
{ key: 'agents', label: t('homeDashboard.intelFilterAgents') },
]
const slideVariants = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: {
initial: { opacity: 0, x: 24, scale: 0.98 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: -24, scale: 0.98 },
}
const renderSpotlight = (item: IntelItem) => {
switch (item.kind) {
case 'insight': {
const { insight } = item
const text = localizeInsightText(insight)
const excerpt = insight.note1Excerpt || insight.note2Excerpt
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={12} className="text-indigo-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{t('homeDashboard.semanticConnection')}
</span>
{!insight.viewed && (
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
)}
</div>
<ConnectionDiagram
note1Title={insight.note1.title || t('homeDashboard.untitled')}
note2Title={insight.note2.title || t('homeDashboard.untitled')}
score={insight.score}
/>
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
« {text} »
</p>
{excerpt && (
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
{excerpt}
</p>
)}
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
>
<GitCompare size={9} />
{t('homeDashboard.intelCompare')}
</button>
<button
type="button"
disabled={dismissingInsightId === insight.id}
onClick={() => onDismissInsight(insight)}
className="p-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 ms-auto disabled:opacity-40"
title={t('homeDashboard.dismissConnection')}
>
{dismissingInsightId === insight.id
? <Loader2 size={10} className="animate-spin" />
: <X size={10} />}
</button>
</div>
</div>
)
}
case 'bridge': {
const { bridge } = item
const title = bridge.note?.title || t('homeDashboard.untitled')
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
const names = bridge.clusterNames ?? []
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<Zap size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{t('homeDashboard.bridgeNote')}
</span>
</div>
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
{Math.round(bridge.bridgeScore * 100)}%
</span>
</div>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="text-start group flex-1"
>
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
{title}
</p>
{excerpt && (
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
)}
{names.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{names.map((name, i) => (
<span
key={`${bridge.noteId}-${name}`}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-border/25 bg-white/70 dark:bg-zinc-900/50"
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
/>
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
</span>
))}
</div>
)}
</button>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
</div>
)
}
case 'suggestion': {
const { suggestion } = item
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
const busy = actingBridgeSuggestionKey === key
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Lightbulb size={12} className="text-violet-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
{t('homeDashboard.intelMissingLink')}
</span>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
{suggestion.clusterAName}
</span>
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
{suggestion.clusterBName}
</span>
</div>
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
<button
type="button"
disabled={busy}
onClick={() => onCreateBridgeSuggestion(suggestion)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
>
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={busy}
onClick={() => onDismissBridgeSuggestion(suggestion)}
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
</div>
</div>
)
}
case 'agent': {
const { agent } = item
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Bot size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{agent.agentName}
</span>
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
{formatRelativeTime(agent.createdAt, t)}
</span>
</div>
{agent.result ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
{agent.result}
</p>
) : (
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
)}
<button
type="button"
onClick={() => router.push('/agents')}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
>
{t('homeDashboard.intelViewAgent')}
<ArrowRight size={9} />
</button>
</div>
)
}
}
}
const spotlightAccent = (item: IntelItem | null) => {
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
switch (item.kind) {
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
}
}
return (
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-900 border border-border/30 flex flex-col">
{/* Header */}
<DashboardWidgetTitleRow
widgetId="intelligence"
icon={(
<div className="w-5 h-5 rounded bg-brand-accent/10 flex items-center justify-center text-brand-accent shrink-0">
<Sparkles size={12} />
</div>
)}
title={t('homeDashboard.aiFound')}
actions={(
<>
{newCount > 0 && (
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
{newCount} {t('homeDashboard.new')}
</span>
)}
{aiActive && (
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
title={t('homeDashboard.analyzeNotes')}
className="p-1.5 rounded-lg border border-border/30 hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all disabled:opacity-40"
>
{echoRefreshing
? <Loader2 size={11} className="animate-spin text-brand-accent" />
: <RefreshCw size={11} className="text-concrete hover:text-brand-accent" />}
</button>
)}
</>
)}
/>
{loading ? (
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
) : !aiActive ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<p className="text-xs text-concrete leading-relaxed">
{!hasAiConsent
? t('homeDashboard.aiConsentRequired')
: !providerReady
? t('homeDashboard.aiProviderUnavailable')
: t('homeDashboard.memoryEchoDisabled')}
</p>
{!hasAiConsent && (
<button
type="button"
onClick={onEnableAi}
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
>
{t('homeDashboard.enableAi')}
</button>
)}
</div>
) : allItems.length === 0 ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
>
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{t('homeDashboard.analyzeNotes')}
</button>
</div>
) : (
<>
{/* Filtres */}
<div className="flex gap-1 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5 mb-3">
{filters.map(f => {
const count = counts[f.key]
if (f.key !== 'all' && count === 0) return null
const active = filter === f.key
return (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
active
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
}`}
>
{f.label}
{count > 0 && (
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{filteredItems.length === 0 ? (
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
) : (
<>
{/* Spotlight carousel */}
<div className="relative">
{filteredItems.length > 1 && (
<>
<button
type="button"
onClick={goPrev}
disabled={activeIndex === 0}
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={14} />
</button>
<button
type="button"
onClick={goNext}
disabled={activeIndex >= filteredItems.length - 1}
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={14} />
</button>
</>
)}
<div
className={`min-h-[200px] mx-5 p-4 rounded-2xl border bg-gradient-to-br ${spotlightAccent(activeItem)} transition-colors`}
>
<AnimatePresence mode="wait">
{activeItem && (
<motion.div
key={activeItem.id}
initial={slideVariants.initial}
animate={slideVariants.animate}
exit={slideVariants.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: 'easeOut' }}
className="min-h-[180px] flex flex-col"
>
{renderSpotlight(activeItem)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Navigation dots + position */}
{filteredItems.length > 1 && (
<div className="flex items-center justify-center gap-3 mt-3">
<div className="flex items-center gap-1.5">
{filteredItems.map((item, idx) => (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`rounded-full transition-all ${
idx === activeIndex
? 'w-5 h-1.5 bg-ochre'
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
}`}
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
/>
))}
</div>
<span className="text-[8px] font-mono text-concrete/60 uppercase">
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
</span>
</div>
)}
{/* File d'attente compacte */}
{filteredItems.length > 1 && (
<div className="flex gap-1.5 mt-3 overflow-x-auto custom-scrollbar pt-1">
{filteredItems.map((item, idx) => {
const isActive = idx === activeIndex
let label = ''
let Icon = Sparkles
let accent = 'text-indigo-500'
if (item.kind === 'insight') {
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Sparkles
accent = 'text-indigo-500'
} else if (item.kind === 'bridge') {
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Zap
accent = 'text-ochre'
} else if (item.kind === 'suggestion') {
label = item.suggestion.suggestedTitle.slice(0, 28)
Icon = Lightbulb
accent = 'text-violet-500'
} else {
label = item.agent.agentName
Icon = Bot
accent = 'text-ochre'
}
return (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
isActive
? 'border-ochre/40 bg-ochre/5 shadow-sm'
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
}`}
>
<Icon size={9} className={`shrink-0 ${accent}`} />
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
</button>
)
})}
</div>
)}
</>
)}
</>
)}
</section>
)
}

View File

@@ -10,6 +10,12 @@ import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
import { useLanguage } from '@/lib/i18n'
function SlidesLoadingFallback() {
const { t } = useLanguage()
return <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">{t('lab.loadingSlides')}</div>
}
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
@@ -18,7 +24,7 @@ const Excalidraw = dynamic(
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
{ ssr: false, loading: () => <SlidesLoadingFallback /> }
)
interface CanvasBoardProps {
@@ -62,6 +68,7 @@ function parseCanvasScene(initialData?: string): {
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const { t } = useLanguage()
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
@@ -83,7 +90,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
toast.error(t('lab.downloadFailed'))
}
}
@@ -106,7 +113,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
<Download className="w-5 h-5" />
Download .pptx
{t('lab.exportPpt')}
</button>
<p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
@@ -117,10 +124,14 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const { t } = useLanguage()
const iframeRef = useRef<HTMLIFrameElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 1
// Always preview the generated HTML (KaTeX, themes, density).
// React SlidesRenderer used an older layout schema and looked broken vs HTML.
const [isLoaded, setIsLoaded] = useState(false)
const totalSlides = data.slideCount || data.spec?.slides?.length || 1
// Hide the global AI floating button while slides are displayed
useEffect(() => {
@@ -141,10 +152,36 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
return () => window.removeEventListener('message', handler)
}, [])
// Fit a 16:9 stage into the available panel (letterbox, no crop)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const parent = stage.parentElement
if (!parent) return
const fit = () => {
const pw = parent.clientWidth
const ph = parent.clientHeight
if (pw < 32 || ph < 32) return
// Design frame 16:9 — pick max size that fits
const designW = 1280
const designH = 720
const scale = Math.min(pw / designW, ph / designH)
const w = Math.round(designW * scale)
const h = Math.round(designH * scale)
stage.style.width = `${w}px`
stage.style.height = `${h}px`
}
fit()
const ro = new ResizeObserver(fit)
ro.observe(parent)
return () => ro.disconnect()
}, [])
const navigate = (dir: number) => {
const iframe = iframeRef.current
if (!iframe) return
// Direct contentWindow access (works with allow-same-origin)
try {
const win = iframe.contentWindow as any
if (typeof win?.changeSlide === 'function') {
@@ -152,7 +189,6 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
return
}
} catch (_) {}
// Fallback: postMessage
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
}
@@ -175,6 +211,9 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
// Prefer polished HTML; fall back to React only if html is missing
const useHtml = Boolean(data.html && data.html.length > 200)
return (
<div className="absolute inset-0 flex flex-col bg-zinc-950">
{/* Toolbar */}
@@ -184,7 +223,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<span className="text-sm font-medium truncate">{name}</span>
{totalSlides > 1 && (
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
{currentSlide} / {totalSlides}
{currentSlide}&nbsp;/&nbsp;{totalSlides}
</span>
)}
</div>
@@ -194,69 +233,77 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
href={`/api/canvas/slides/pptx?id=${canvasId}`}
download
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Exporter en PowerPoint"
title={t('lab.exportPpt')}
>
<Download className="w-3.5 h-3.5" />
Export PPTX
{t('lab.exportPpt')}
</a>
)}
<button
onClick={downloadHtml}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Télécharger le HTML"
title={t('lab.downloadHtml')}
>
<Download className="w-3.5 h-3.5" />
Export HTML
{t('lab.downloadHtml')}
</button>
<button
onClick={openFullscreen}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Ouvrir en plein écran"
title={t('lab.openFullscreen')}
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
{t('lab.openFullscreen')}
</button>
</div>
</div>
{/* Slides: React renderer (spec) or iframe (HTML fallback) */}
<div className="flex-1 relative overflow-hidden group">
{data.spec ? (
<SlidesRenderer spec={data.spec} />
) : (
{/* Stage: 16:9 letterboxed HTML (source of visual truth) */}
<div className="flex-1 relative overflow-hidden group flex items-center justify-center bg-zinc-950 p-3 sm:p-4">
{useHtml ? (
<>
{/* Loading overlay — visible until iframe fires onLoad */}
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">Chargement de la présentation</span>
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
{/* Prev button */}
<div
ref={stageRef}
className="relative overflow-hidden rounded-lg shadow-2xl shadow-black/50 border border-white/10 bg-black"
style={{ width: '100%', maxWidth: '100%', aspectRatio: '16 / 9' }}
>
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
</div>
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide précédent"
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label={t('lab.previousSlide')}
>
<ChevronLeft className="w-5 h-5" />
</button>
{/* Next button */}
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide suivant"
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label={t('lab.nextSlide')}
>
<ChevronRight className="w-5 h-5" />
</button>
</>
) : data.spec ? (
<div className="w-full h-full">
<SlidesRenderer spec={data.spec} />
</div>
) : (
<div className="text-white/40 text-sm">{t('lab.loadingPresentation')}</div>
)}
</div>
</div>

View File

@@ -11,10 +11,16 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
import { useLanguage } from '@/lib/i18n'
function MermaidLoadingFallback() {
const { t } = useLanguage()
return <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>{t('common.loading')}</div>
}
const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement</div> }
{ ssr: false, loading: () => <MermaidLoadingFallback /> }
)
// ── Constants ──────────────────────────────────────────────────────────────────
@@ -688,6 +694,7 @@ export interface SlidesRendererProps {
}
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const { t } = useLanguage()
const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -795,12 +802,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
{/* Navigation buttons */}
{current > 0 && (
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
<button style={navBtn(true)} onClick={prev} aria-label={t('lab.previous')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
)}
{current < total - 1 && (
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
<button style={navBtn(false)} onClick={next} aria-label={t('lab.next')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
</button>
)}
@@ -830,12 +837,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
Notes de présentation
{t('lab.notes')}
</span>
<button
onClick={() => setShowNotes(false)}
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
title="Masquer les notes"
title={t('lab.hideNotes')}
>
</button>
@@ -860,10 +867,10 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
backdropFilter: 'blur(4px)',
}}
title="Bascule des notes de présentation (Raccourci: N)"
title={t('lab.toggleNotes')}
>
<span>📝</span>
<span>Notes</span>
<span>{t('lab.notes')}</span>
</button>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>

View File

@@ -1,31 +1,72 @@
'use client'
import { motion } from 'motion/react'
import { motion, AnimatePresence } from 'motion/react'
import {
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
Box
ArrowRight, Menu, X, Check, BrainCircuit,
Network, GraduationCap, Bot, KeyRound, Globe, ChevronDown
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { useEffect, useRef, useState, type ReactNode } from 'react'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
const LANDING_LANGS: { code: SupportedLanguage; labelKey: string }[] = [
{ code: 'fr', labelKey: 'languages.fr' },
{ code: 'en', labelKey: 'languages.en' },
{ code: 'es', labelKey: 'languages.es' },
{ code: 'de', labelKey: 'languages.de' },
{ code: 'it', labelKey: 'languages.it' },
{ code: 'pt', labelKey: 'languages.pt' },
{ code: 'nl', labelKey: 'languages.nl' },
{ code: 'pl', labelKey: 'languages.pl' },
{ code: 'ru', labelKey: 'languages.ru' },
{ code: 'zh', labelKey: 'languages.zh' },
{ code: 'ja', labelKey: 'languages.ja' },
{ code: 'ko', labelKey: 'languages.ko' },
{ code: 'ar', labelKey: 'languages.ar' },
{ code: 'fa', labelKey: 'languages.fa' },
{ code: 'hi', labelKey: 'languages.hi' },
]
export function LandingPage() {
const { t } = useLanguage()
const router = useRouter()
const { t, language, setLanguage } = useLanguage()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const [menuOpen, setMenuOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const [echoIndex, setEchoIndex] = useState(0)
const langRef = useRef<HTMLDivElement>(null)
const AGENTS = [
{ key: 'scraper', icon: <Globe size={24} /> },
{ key: 'researcher', icon: <Search size={24} /> },
{ key: 'slideGen', icon: <Layers size={24} /> },
{ key: 'monitor', icon: <Activity size={24} /> },
{ key: 'diagramGen', icon: <Box size={24} /> },
{ key: 'custom', icon: <Workflow size={24} /> },
]
useEffect(() => {
if (!langOpen) return
const onPointer = (e: MouseEvent) => {
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLangOpen(false)
}
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [langOpen])
useEffect(() => {
const id = setInterval(() => setEchoIndex((i) => (i + 1) % ECHO_LINES.length), 3200)
return () => clearInterval(id)
}, [])
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
useEffect(() => {
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
if (!root) return
const prev = root.style.overflow
if (menuOpen) root.style.overflow = 'hidden'
else root.style.overflow = prev || ''
return () => { root.style.overflow = prev }
}, [menuOpen])
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
@@ -34,380 +75,556 @@ export function LandingPage() {
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
]
const TECH_TIERS = [
{ key: 'tags', color: 'bg-brand-accent' },
{ key: 'embeddings', color: 'bg-ochre' },
{ key: 'chatRag', color: 'bg-ink' },
const NAV = [
{ href: '#product', label: t('landing.nav.secondBrain') },
{ href: '#echo', label: t('landing.nav.echo') },
{ href: '#agents', label: t('landing.nav.agents') },
{ href: '#pricing', label: t('landing.nav.pricing') },
]
const FOOTER_SECTIONS = ['product', 'community', 'legal'] as const
return (
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-4 sm:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
<span className="text-paper font-serif text-2xl font-bold">M</span>
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
{/* Nav */}
<nav className="fixed top-0 left-0 right-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg transition-transform group-hover:scale-105">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
<span className="font-serif text-xl font-medium tracking-tight text-[#F4F1EA]">Memento</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
{NAV.map((l) => (
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors">
{l.label}
</a>
))}
</div>
<div className="flex items-center gap-2 sm:gap-3">
{/* Language switcher */}
<div ref={langRef} className="relative">
<button
type="button"
onClick={() => setLangOpen((o) => !o)}
aria-expanded={langOpen}
aria-haspopup="listbox"
aria-label={t('landing.nav.language')}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/15 text-[12px] text-white/70 hover:text-white hover:border-white/30 transition-colors"
>
<Globe size={14} />
<span className="uppercase font-semibold tracking-wide">{language}</span>
<ChevronDown size={12} className={`opacity-60 transition-transform ${langOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{langOpen && (
<motion.ul
role="listbox"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.15 }}
className="absolute end-0 mt-2 w-48 max-h-72 overflow-y-auto rounded-2xl border border-white/10 bg-[#141210] shadow-2xl py-1.5 z-[110]"
>
{LANDING_LANGS.map((lang) => (
<li key={lang.code} role="option" aria-selected={language === lang.code}>
<button
type="button"
onClick={() => {
setLanguage(lang.code)
setLangOpen(false)
}}
className={`w-full text-start px-4 py-2.5 text-[13px] transition-colors ${
language === lang.code
? 'bg-[#D4A373]/15 text-[#D4A373]'
: 'text-white/70 hover:bg-white/5 hover:text-white'
}`}
>
{t(lang.labelKey)}
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<div className="hidden md:flex items-center gap-10">
<a href="#features" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.features')}</a>
<a href="#agents" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.agents')}</a>
<a href="#brainstorm" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.brainstorm')}</a>
<a href="#pricing" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.pricing')}</a>
<a href="#tech" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.tech')}</a>
</div>
<div className="flex items-center gap-4">
<button onClick={() => router.push('/login')} className="hidden md:block px-6 py-2.5 text-concrete hover:text-ink text-[11px] font-bold uppercase tracking-widest transition-colors">
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</button>
<button onClick={() => router.push('/register')} className="px-6 py-2.5 bg-ink text-paper rounded-full text-[11px] font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2 group shadow-xl shadow-ink/10">
</Link>
<Link
href="/register"
className="hidden sm:inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
>
{t('landing.nav.cta')}
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</Link>
<button
type="button"
aria-label={menuOpen ? t('landing.nav.closeMenu') : t('landing.nav.openMenu')}
onClick={() => setMenuOpen((o) => !o)}
className="lg:hidden w-10 h-10 rounded-full border border-white/15 flex items-center justify-center text-white/80"
>
{menuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</nav>
{/* Hero */}
<section className="relative pt-40 pb-32 px-8 overflow-hidden">
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-ochre/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/4 -z-10" />
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-brand-accent/5 rounded-full blur-[100px] translate-y-1/2 -translate-x-1/4 -z-10" />
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[99] bg-[#0B0A09] pt-24 px-8 lg:hidden"
>
<div className="flex flex-col gap-1">
{NAV.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setMenuOpen(false)}
className="py-4 text-3xl font-serif border-b border-white/10"
>
{l.label}
</a>
))}
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
{t('landing.nav.cta')}
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="max-w-6xl mx-auto text-center">
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1] }}>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-ochre/10 border border-ochre/20 text-ochre text-[10px] font-bold uppercase tracking-[0.2em] mb-8">
<Sparkles size={12} />
{/* ── HERO ── */}
<section className="relative min-h-[100dvh] flex flex-col justify-center pt-28 pb-16 px-5 sm:px-8 overflow-hidden">
{/* Atmosphere — warm, not purple neon */}
<div className="pointer-events-none absolute inset-0">
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-[#D4A373]/15 blur-[120px] rounded-full" />
<div className="absolute bottom-0 right-0 w-[500px] h-[400px] bg-[#A47148]/10 blur-[100px] rounded-full" />
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, #F4F1EA 1px, transparent 0)',
backgroundSize: '28px 28px',
}}
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto w-full text-center">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-[#D4A373]/35 bg-[#D4A373]/10 mb-7">
<BrainCircuit size={14} className="text-[#D4A373]" />
<span className="text-[12px] tracking-[0.18em] uppercase text-[#D4A373] font-semibold">
{t('landing.hero.badge')}
</div>
<h1 className="text-6xl md:text-8xl font-serif font-medium tracking-tight text-ink mb-8 leading-[1.1]">
{t('landing.hero.title1')} <br />
<span className="italic">{t('landing.hero.title2')}</span>
</h1>
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
{t('landing.hero.subtitle')}
</p>
</span>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => router.push('/register')} className="px-10 py-5 bg-ink text-paper rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-95 transition-all shadow-2xl shadow-ink/20 flex items-center gap-4 group">
{t('landing.hero.cta')}
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</button>
<a href="#features" className="px-10 py-5 border border-border rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-50 transition-all">
{t('landing.hero.secondary')}
</a>
</div>
</motion.div>
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium">
{t('landing.hero.eyebrow')}
</p>
{/* App Preview Mockup */}
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
<Image src="/images/workspace-hero.jpg" alt="Memento Workspace" width={1200} height={750} className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" priority />
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
<h1 className="font-serif text-[clamp(2.5rem,7.5vw,5.4rem)] font-medium tracking-tight leading-[1.05] text-[#F4F1EA] mb-6">
{t('landing.hero.headline')}
<br />
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
</h1>
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<BrainCircuit size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('landing.hero.memoryEcho')}</span>
</div>
<p className="text-xs font-serif italic text-ink/70">{t('landing.hero.memoryEchoText')}</p>
</div>
<div className="absolute bottom-10 left-10 w-72 bg-ink text-paper p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-ochre" />
<span className="text-[10px] font-bold uppercase tracking-widest text-ochre">{t('landing.hero.brainstormLive')}</span>
</div>
<div className="flex items-center -space-x-2">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-ink bg-concrete text-[8px] flex items-center justify-center font-bold">JD</div>
))}
<span className="text-[10px] ml-4 text-paper/60">{t('landing.hero.ideasGenerated')}</span>
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10">
{t('landing.hero.subtitle')}
</p>
<div className="flex flex-col items-center gap-3">
<Link
href="/register"
className="group inline-flex items-center justify-center gap-3 px-9 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02] shadow-[0_0_40px_-8px_rgba(212,163,115,0.45)]"
>
{t('landing.hero.cta')}
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
</Link>
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p>
</div>
</div>
{/* Product stage */}
<div className="relative z-10 mt-14 sm:mt-20 max-w-5xl mx-auto w-full">
<div className="relative rounded-[20px] sm:rounded-[28px] overflow-hidden border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.7)]">
<div className="absolute inset-0 bg-gradient-to-t from-[#0B0A09] via-transparent to-transparent z-10 pointer-events-none" />
<Image
src="/images/workspace-hero.jpg"
alt={t('landing.hero.imageAlt')}
width={2000}
height={1200}
priority
className="w-full h-auto object-cover aspect-[16/10] opacity-90"
/>
{/* Live Memory Echo chip */}
<div className="absolute bottom-5 left-5 right-5 sm:bottom-8 sm:left-8 sm:right-auto sm:max-w-md z-20">
<div className="rounded-2xl border border-white/15 bg-[#0B0A09]/85 backdrop-blur-xl p-4 sm:p-5 text-left shadow-2xl">
<div className="flex items-center gap-2 mb-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#D4A373] opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#D4A373]" />
</span>
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#D4A373]">
{t('landing.hero.echoLive')}
</span>
</div>
<AnimatePresence mode="wait">
<motion.p
key={ECHO_LINES[echoIndex]}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35 }}
className="text-sm sm:text-[15px] font-serif italic text-[#F4F1EA]/90 leading-relaxed"
>
{t(`landing.hero.${ECHO_LINES[echoIndex]}`)}
</motion.p>
</AnimatePresence>
</div>
</div>
</motion.div>
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto">
<div className="mb-24 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="max-w-2xl">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.features.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">{t('landing.features.title')} <br />{t('landing.features.title2')}</h2>
</div>
<div className="text-concrete font-light">{t('landing.features.desc')}</div>
</div>
{/* Trust strip */}
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide">
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
<span key={k} className="flex items-center gap-2">
<Check size={14} className="text-[#D4A373]" />
{t(`landing.trust.${k}`)}
</span>
))}
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
{['f1', 'f2', 'f3'].map((f, i) => {
const icons = [
<Search key="s" className="text-brand-accent" />,
<MessageSquare key="m" className="text-ochre" />,
<Zap key="z" className="text-ink" />
]
return (
<div key={f} className="group">
<div className="w-14 h-14 bg-slate-50 border border-border rounded-2xl flex items-center justify-center mb-8 group-hover:bg-ink group-hover:text-paper transition-all duration-300">
{icons[i]}
</div>
<h3 className="text-xl font-serif font-medium mb-4">{t(`landing.features.${f}Title`)}</h3>
<p className="text-sm text-concrete leading-relaxed font-light">{t(`landing.features.${f}Desc`)}</p>
{/* Pain → Promise */}
<section className="px-5 sm:px-8 py-24 sm:py-32">
<div className="max-w-3xl mx-auto text-center">
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-5 font-medium">{t('landing.pain.label')}</p>
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
{t('landing.pain.title')}
</h2>
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
{t('landing.pain.secondBrain')}
</p>
</div>
</section>
{/* Product moments — large narrative blocks, not wireframe cards */}
<section id="product" className="px-5 sm:px-8 pb-8">
<div className="max-w-6xl mx-auto space-y-4">
<ProductMoment
id="echo"
eyebrow={t('landing.moments.echo.eyebrow')}
title={t('landing.moments.echo.title')}
desc={t('landing.moments.echo.desc')}
dark
>
<div className="space-y-3 p-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className={`rounded-xl border p-4 ${i === 0 ? 'border-[#D4A373]/40 bg-[#D4A373]/10' : 'border-white/10 bg-white/[0.03]'}`}
>
<p className="text-[11px] uppercase tracking-wider text-[#D4A373] mb-1">
{t(`landing.moments.echo.card${i}Label`)}
</p>
<p className="text-sm text-white/75 font-serif italic leading-relaxed">
{t(`landing.moments.echo.card${i}`)}
</p>
</div>
)
})}
))}
</div>
</ProductMoment>
<ProductMoment
eyebrow={t('landing.moments.dashboard.eyebrow')}
title={t('landing.moments.dashboard.title')}
desc={t('landing.moments.dashboard.desc')}
>
<div className="grid grid-cols-2 gap-2 p-2">
{['w0', 'w1', 'w2', 'w3'].map((w) => (
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
</div>
))}
</div>
</ProductMoment>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ProductMoment
compact
eyebrow={t('landing.moments.insights.eyebrow')}
title={t('landing.moments.insights.title')}
desc={t('landing.moments.insights.desc')}
dark
>
<div className="relative h-36 flex items-center justify-center">
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35">
{t('landing.moments.insights.chip')}
</p>
</div>
</ProductMoment>
<ProductMoment
compact
eyebrow={t('landing.moments.revision.eyebrow')}
title={t('landing.moments.revision.title')}
desc={t('landing.moments.revision.desc')}
>
<div className="rounded-xl bg-white border border-black/5 p-5 mx-2 mb-2">
<GraduationCap className="text-[#A47148] mb-3" size={22} />
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
<div className="flex gap-2">
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span>
</div>
</div>
</ProductMoment>
</div>
</div>
</section>
{/* How it works */}
<section className="px-5 sm:px-8 py-28">
<div className="max-w-5xl mx-auto">
<h2 className="font-serif text-3xl sm:text-4xl tracking-tight text-center mb-16">
{t('landing.how.title')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6">
{['s0', 's1', 's2'].map((s, i) => (
<div key={s} className="relative text-center md:text-left">
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Agents */}
<section id="agents" className="py-32 px-8 bg-ink text-paper overflow-hidden relative">
<div className="absolute top-0 right-0 w-[1000px] h-[1000px] bg-brand-accent/10 rounded-full blur-[150px] -translate-y-1/2 translate-x-1/2" />
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-24">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.agents.label')}</span>
<h2 className="text-4xl md:text-6xl font-serif tracking-tight mb-8">{t('landing.agents.title')}</h2>
<p className="text-paper/60 max-w-xl mx-auto font-light">{t('landing.agents.desc')}</p>
<section id="agents" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
<div className="max-w-5xl mx-auto">
<div className="max-w-2xl mb-14">
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
<p className="text-white/50 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{AGENTS.map((agent, i) => (
<div key={i} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all cursor-default group">
<div className="text-ochre mb-6 transition-transform group-hover:scale-110 duration-300">{agent.icon}</div>
<h4 className="text-xl font-serif font-medium mb-4">{t(`landing.agents.${agent.key}.title`)}</h4>
<p className="text-sm text-paper/50 leading-relaxed font-light">{t(`landing.agents.${agent.key}.desc`)}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
<Bot size={18} className="text-[#D4A373] mb-4" />
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Brainstorm */}
<section id="brainstorm" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-24">
{/* BYOK */}
<section className="px-5 sm:px-8 py-20">
<div className="max-w-5xl mx-auto rounded-[28px] border border-white/10 bg-gradient-to-br from-[#1a1612] to-[#0B0A09] p-8 sm:p-12 flex flex-col md:flex-row gap-10 items-start">
<div className="flex-1">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.brainstorm.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-8 leading-tight">{t('landing.brainstorm.title')}</h2>
<div className="space-y-8">
{BRAINSTORM_ITEMS.map((item, i) => (
<div key={item} className="flex gap-6">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-ochre/10 text-ochre flex items-center justify-center font-bold text-xs">{i + 1}</div>
<div>
<h5 className="font-bold text-sm mb-1">{t(`landing.brainstorm.${item}.title`)}</h5>
<p className="text-sm text-concrete font-light leading-relaxed">{t(`landing.brainstorm.${item}.desc`)}</p>
</div>
</div>
))}
<div className="flex items-center gap-2 text-[#D4A373] mb-4">
<KeyRound size={18} />
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
</div>
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p>
</div>
<div className="flex-1 relative">
<div className="w-[450px] h-[450px] border-2 border-dashed border-border rounded-full flex items-center justify-center relative">
<div className="absolute top-0 right-1/2 translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-ink rounded-full" />
<div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-ochre rounded-full" />
<div className="w-[300px] h-[300px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-[150px] h-[150px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-12 h-12 bg-ink rounded-xl shadow-2xl flex items-center justify-center text-paper font-serif text-xl">M</div>
</div>
</div>
</div>
<div className="absolute top-10 right-0 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-ochre">{t('landing.brainstorm.disruptionLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.disruptionText')}</p>
</div>
<div className="absolute bottom-20 -left-10 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-brand-accent">{t('landing.brainstorm.analogyLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.analogyText')}</p>
</div>
</div>
</div>
</section>
{/* Tech */}
<section id="tech" className="py-32 px-8 bg-slate-50 border-y border-border">
<div className="max-w-6xl mx-auto text-center">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.tech.label')}</span>
<h2 className="text-4xl font-serif tracking-tight mb-16">{t('landing.tech.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-700">
{['OpenAI', 'Google', 'Anthropic', 'DeepSeek', 'Mistral', 'Meta', 'Ollama', 'Groq', 'X.AI', 'Custom'].map((brand, i) => (
<div key={i} className="flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-white rounded-xl border border-border flex items-center justify-center text-xs font-black tracking-tighter">
{brand.slice(0, 2).toUpperCase()}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{brand}</span>
</div>
))}
</div>
<div className="mt-24 max-w-2xl mx-auto p-1 bg-white rounded-3xl border border-border shadow-sm flex flex-col md:flex-row gap-0.5">
{TECH_TIERS.map((tier, i) => (
<div key={i} className="flex-1 p-6 text-left">
<div className={`w-1.5 h-1.5 rounded-full ${tier.color} mb-4`} />
<h6 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-2">{t(`landing.tech.${tier.key}.title`)}</h6>
<p className="text-xs font-light text-concrete">{t(`landing.tech.${tier.key}.desc`)}</p>
</div>
))}
<div className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5">
<p className="text-[#D4A373]">{'{'}</p>
<p className="pl-3">&quot;provider&quot;: &quot;anthropic&quot;,</p>
<p className="pl-3">&quot;model&quot;: &quot;claude-sonnet&quot;,</p>
<p className="pl-3 text-[#F4F1EA]/70">&quot;apiKey&quot;: &quot;sk-ant-&quot;,</p>
<p className="pl-3">&quot;yours&quot;: true</p>
<p className="text-[#D4A373]">{'}'}</p>
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-32 px-8 bg-paper">
<div className="max-w-7xl mx-auto">
<section id="pricing" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.monthly')}
</button>
<button
type="button"
onClick={() => setBillingInterval('annual')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
</button>
<div className="relative">
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
{billingInterval === 'annual' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan, i) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{PLANS.map((plan) => (
<div
key={plan.key}
className={`rounded-2xl border p-6 flex flex-col ${
plan.popular
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
: 'border-white/[0.08] bg-white/[0.02]'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
{t('landing.pricing.popular')}
</div>
</span>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2">
{t(`landing.pricing.${plan.key}.name`)}
</h4>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
</div>
<div className="flex-1 space-y-4 mb-10">
{[0, 1, 2, 3, 4, 5].map(j => {
<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">
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
if (!feat || feat.startsWith('landing.')) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
<li key={j} className="flex gap-2 text-xs text-white/60">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat}
</li>
)
})}
</div>
<button onClick={() => router.push('/register')} className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}>
</ul>
<Link
href="/register"
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
</button>
</Link>
</div>
))}
</div>
{/* BYOK */}
<div className="mt-20 p-12 bg-slate-50 border border-border rounded-[40px] flex flex-col md:flex-row items-center gap-12">
<div className="flex-1">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-accent/10 text-brand-accent text-[9px] font-bold uppercase tracking-widest mb-6">
<Cpu size={12} />
{t('landing.byok.label')}
</div>
<h3 className="text-3xl font-serif font-medium mb-4">{t('landing.byok.title')}</h3>
<p className="text-concrete font-light leading-relaxed mb-6">{t('landing.byok.desc')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.noLockin')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.noLockinDesc')}</p>
</div>
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.cost')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.costDesc')}</p>
</div>
</div>
</div>
<div className="w-full md:w-[400px] bg-ink rounded-3xl p-8 relative overflow-hidden group">
<div className="absolute inset-0 bg-brand-accent/10 blur-[50px] group-hover:bg-ochre/10 transition-colors" />
<div className="relative z-10 font-mono text-[10px] text-paper/40 space-y-2">
<p className="text-ochre">{"{"}</p>
<p className="pl-4">"provider": "anthropic",</p>
<p className="pl-4">"model": "claude-3-opus",</p>
<p className="pl-4 border-l border-brand-accent/30 bg-brand-accent/5">"apiKey": "sk-ant-at03-..."</p>
<p className="pl-4">"useSystemKey": false</p>
<p className="text-ochre">{"}"}</p>
</div>
<div className="mt-8 flex items-center justify-between relative z-10">
<span className="text-[10px] font-bold text-paper uppercase tracking-widest">{t('landing.byok.configLabel')}</span>
<div className="flex gap-1">{[1, 2, 3].map(i => <div key={i} className="w-1 h-1 rounded-full bg-paper/20" />)}</div>
</div>
</div>
</div>
</div>
</section>
{/* Final CTA */}
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
<div className="max-w-3xl mx-auto relative z-10">
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">{t('landing.cta.title1')} <br /><span className="italic">{t('landing.cta.title2')}</span></h2>
<p className="text-lg text-concrete font-light mb-12">{t('landing.cta.desc')}</p>
<button onClick={() => router.push('/register')} className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]">
<section className="relative px-5 sm:px-8 py-36 text-center overflow-hidden">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-[#D4A373]/20 blur-[100px] rounded-full" />
</div>
<div className="relative z-10 max-w-2xl mx-auto">
<BrainCircuit className="mx-auto text-[#D4A373] mb-6" size={32} strokeWidth={1.25} />
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
{t('landing.cta.title')}
</h2>
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p>
<Link
href="/register"
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
>
{t('landing.cta.button')}
</button>
<ArrowRight size={18} />
</Link>
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p>
</div>
</section>
{/* Footer */}
<footer className="py-20 px-8 border-t border-border bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-12">
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
<span className="text-paper font-serif text-lg font-bold">M</span>
<footer className="px-5 sm:px-8 py-14 border-t border-white/[0.06]">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-10">
<div className="max-w-xs">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-md">
<span className="font-serif font-bold text-sm">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
<span className="font-serif text-lg">Memento</span>
</div>
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
<p className="text-sm text-white/35">{t('landing.footer.desc')}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-16">
{FOOTER_SECTIONS.map(section => (
<div key={section} className="space-y-4">
<h6 className="text-[10px] font-bold uppercase tracking-widest text-ink">{t(`landing.footer.${section}.title`)}</h6>
<ul className="space-y-2 text-sm text-concrete font-light">
{[0, 1, 2].map(j => {
<div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}>
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3">
{t(`landing.footer.${section}.title`)}
</p>
<ul className="space-y-2 text-white/50">
{[0, 1, 2].map((j) => {
const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`)
if (!label || label.startsWith('landing.')) return null
return <li key={j}><a href={href} className="hover:text-ink">{label}</a></li>
return (
<li key={j}>
<a href={href} className="hover:text-white transition-colors">{label}</a>
</li>
)
})}
</ul>
</div>
))}
</div>
</div>
<div className="max-w-6xl mx-auto mt-20 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold tracking-widest text-concrete">
<div>© 2026 MOMENTO LABS. ALL RIGHTS RESERVED.</div>
<div className="flex gap-8"><span>DESIGNED BY ANTIGRAVITY</span></div>
</div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')}
</p>
</footer>
</div>
)
}
function ProductMoment({
id,
eyebrow,
title,
desc,
children,
dark,
compact,
}: {
id?: string
eyebrow: string
title: string
desc: string
children: ReactNode
dark?: boolean
compact?: boolean
}) {
return (
<motion.div
id={id}
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.55 }}
className={`rounded-[24px] sm:rounded-[32px] border overflow-hidden ${
dark
? 'bg-[#141210] border-white/[0.08] text-[#F4F1EA]'
: 'bg-[#EDE8DF] border-transparent text-[#0B0A09]'
} ${compact ? '' : 'grid grid-cols-1 lg:grid-cols-2'}`}
>
<div className={`p-8 sm:p-10 flex flex-col justify-center ${compact ? 'pb-4' : ''}`}>
<p className={`text-[11px] uppercase tracking-[0.22em] font-semibold mb-3 ${dark ? 'text-[#D4A373]' : 'text-[#A47148]'}`}>
{eyebrow}
</p>
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p>
</div>
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
<div className="w-full">{children}</div>
</div>
</motion.div>
)
}

View File

@@ -41,9 +41,16 @@ import { cn } from '@/lib/utils'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
mcpAllowed?: boolean
tier?: string
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
export function McpSettingsPanel({
initialKeys,
serverStatus,
mcpAllowed = true,
tier = 'PRO',
}: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
@@ -69,7 +76,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.generate'))
}
})
}
@@ -84,7 +91,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.revoke'))
}
})
}
@@ -97,7 +104,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.delete'))
}
})
}
@@ -183,6 +190,11 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div>
</div>
{!mcpAllowed ? (
<p className="text-[10px] text-concrete max-w-[200px] text-end leading-relaxed">
{t('mcpSettings.tierRequired', { tier })}
</p>
) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20">
@@ -192,8 +204,17 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
</DialogTrigger>
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
</Dialog>
)}
</div>
{!mcpAllowed && (
<div className="px-6 pb-4">
<p className="text-[11px] text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
{t('mcpSettings.upgradeHint')}
</p>
</div>
)}
<div className="p-6">
{keys.length === 0 ? (
<div className="text-center py-8">

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
import { Button } from '@/components/ui/button'
@@ -12,7 +12,6 @@ import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
interface ConnectionData {
noteId: string
@@ -106,6 +105,9 @@ export function MemoryEchoSection({
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const [sectionInView, setSectionInView] = useState(false)
const [cueDismissed, setCueDismissed] = useState(false)
useEffect(() => {
let cancelled = false
@@ -113,6 +115,8 @@ export function MemoryEchoSection({
setConsentRequired(false)
setConnections([])
setRetroRefs([])
setCueDismissed(false)
setSectionInView(false)
const load = async () => {
try {
@@ -154,18 +158,24 @@ export function MemoryEchoSection({
}
}, [noteId])
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
// Observe section visibility — never auto-scroll; cue only when off-screen
useEffect(() => {
if (isLoading || connections.length === 0) return
const top = connections[0]
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
const key = `memory-echo-scroll-${noteId}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
requestAnimationFrame(() => {
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}, [isLoading, connections, noteId])
const el = sectionRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08)
},
{ root: null, threshold: [0, 0.08, 0.2, 0.5] },
)
observer.observe(el)
return () => observer.disconnect()
}, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible])
const scrollToSection = useCallback(() => {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [])
const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId)
@@ -251,8 +261,29 @@ export function MemoryEchoSection({
const topConnection = connections[0]
const restConnections = connections.slice(1)
// Floating cue: signal activity below without stealing the title viewport
const showBottomCue =
!cueDismissed &&
!sectionInView &&
(isLoading || hasConnections || hasRetro || consentRequired)
const cueLabel = isLoading
? t('memoryEcho.editorSection.bottomCueLoading')
: hasConnections
? connections.length === 1
? t('memoryEcho.editorSection.bottomCueFoundOne')
: t('memoryEcho.editorSection.bottomCueFound', { count: connections.length })
: hasRetro
? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length })
: t('memoryEcho.editorSection.bottomCueConsent')
return (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
<>
<div
id="memory-echo-section"
ref={sectionRef}
className="mt-10 space-y-6 scroll-mt-24"
>
{isLoading && (
<div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
@@ -551,6 +582,47 @@ export function MemoryEchoSection({
/>
)}
</div>
{showBottomCue && (
<div
className={cn(
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2',
'flex items-center gap-1 rounded-full border border-indigo-500/25',
'bg-background/95 shadow-lg shadow-indigo-500/10 backdrop-blur-md',
'pl-3 pr-1.5 py-1.5 max-w-[min(92vw,22rem)]',
'animate-in fade-in slide-in-from-bottom-2 duration-300',
)}
role="status"
aria-live="polite"
>
<button
type="button"
onClick={scrollToSection}
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-indigo-500/5"
title={t('memoryEcho.editorSection.bottomCueScroll')}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-indigo-500" />
) : (
<Sparkles className="h-3.5 w-3.5 shrink-0 text-indigo-500" />
)}
<span className="truncate text-xs font-medium text-foreground/90">
{cueLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-indigo-500/80 animate-bounce" />
</button>
<button
type="button"
onClick={() => setCueDismissed(true)}
className="shrink-0 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('memoryEcho.editorSection.bottomCueDismiss')}
title={t('memoryEcho.editorSection.bottomCueDismiss')}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)
}

View File

@@ -47,7 +47,7 @@ export function MobileActionSheet({
const end = $pos.end(1)
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockSelected') || 'Bloc sélectionné en entier')
toast.success(t('richTextEditor.blockSelected'))
onClose()
}
@@ -62,7 +62,7 @@ export function MobileActionSheet({
const nodeText = editor.state.doc.slice(start, end)
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué')
toast.success(t('richTextEditor.blockDuplicated'))
onClose()
}
@@ -75,7 +75,7 @@ export function MobileActionSheet({
const start = $pos.before(1)
const end = $pos.after(1)
editor.chain().focus().deleteRange({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé')
toast.success(t('richTextEditor.blockDeleted'))
onClose()
}
@@ -85,7 +85,7 @@ export function MobileActionSheet({
onClose()
const tab = action === 'improve' ? 'actions' : 'chat'
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
toast.info(t('richTextEditor.aiActionStarted') || 'IA Note sollicitée...')
toast.info(t('richTextEditor.aiActionStarted'))
}
return createPortal(
@@ -101,26 +101,26 @@ export function MobileActionSheet({
<div className="mobile-action-sheet-body">
{/* Section 1 : Actions de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.blockActions') || 'Actions sur le bloc'}</h4>
<h4 className="section-title">{t('mobile.blockActions')}</h4>
<div className="grid grid-cols-3 gap-2">
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
<FileText size={20} className="text-blue-500" />
<span>{t('mobile.selectAll') || 'Sélectionner tout'}</span>
<span>{t('mobile.selectAll')}</span>
</button>
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
<Copy size={20} className="text-emerald-500" />
<span>{t('mobile.duplicate') || 'Dupliquer'}</span>
<span>{t('mobile.duplicate')}</span>
</button>
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
<Trash2 size={20} className="text-rose-500" />
<span>{t('mobile.delete') || 'Supprimer'}</span>
<span>{t('mobile.delete')}</span>
</button>
</div>
</div>
{/* Section 2 : IA Note */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.aiNote') || 'IA Note'}</h4>
<h4 className="section-title">{t('mobile.aiNote')}</h4>
<div className="grid grid-cols-4 gap-2">
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
<Wand2 size={18} className="text-purple-500 animate-pulse" />
@@ -143,42 +143,42 @@ export function MobileActionSheet({
{/* Section 3 : Format de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.convertFormat') || 'Convertir le format'}</h4>
<h4 className="section-title">{t('mobile.convertFormat')}</h4>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
>
{t('mobile.paragraph') || 'Paragraphe'}
{t('mobile.paragraph')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
>
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1') || 'Titre 1'}
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
>
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2') || 'Titre 2'}
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
>
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3') || 'Titre 3'}
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
>
<Quote size={14} className="inline mr-1" /> {t('mobile.quote') || 'Citation'}
<Quote size={14} className="inline mr-1" /> {t('mobile.quote')}
</button>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export type MobileEditorToolbarProps = {
editor: Editor | null
@@ -19,6 +20,7 @@ export function MobileEditorToolbar({
onOpenActionSheet,
onInsertImage,
}: MobileEditorToolbarProps) {
const { t } = useLanguage()
if (!editor) return null
// Format states
@@ -61,7 +63,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBold && 'active')}
onClick={() => editor.chain().focus().toggleBold().run()}
aria-label="Bold"
aria-label={t('editor.bold')}
>
<Bold size={18} />
</button>
@@ -70,7 +72,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isItalic && 'active')}
onClick={() => editor.chain().focus().toggleItalic().run()}
aria-label="Italic"
aria-label={t('editor.italic')}
>
<Italic size={18} />
</button>
@@ -79,7 +81,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
onClick={() => editor.chain().focus().toggleHighlight().run()}
aria-label="Highlight"
aria-label={t('editor.highlight')}
>
<Highlighter size={18} />
</button>
@@ -88,7 +90,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isLink && 'active')}
onClick={handleLinkPress}
aria-label="Link"
aria-label={t('editor.link')}
>
<Link2 size={18} />
</button>
@@ -97,7 +99,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
aria-label="Bullet List"
aria-label={t('editor.bulletList')}
>
<List size={18} />
</button>
@@ -106,7 +108,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
onClick={() => editor.chain().focus().toggleTaskList().run()}
aria-label="Task List"
aria-label={t('editor.taskList')}
>
<CheckSquare size={18} />
</button>
@@ -115,7 +117,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHeading && 'active')}
onClick={toggleHeadingCycle}
aria-label="Heading"
aria-label={t('editor.heading')}
>
<Heading size={18} />
</button>
@@ -124,7 +126,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
aria-label="Code Block"
aria-label={t('editor.codeBlock')}
>
<Code size={18} />
</button>
@@ -133,7 +135,7 @@ export function MobileEditorToolbar({
type="button"
className="mobile-toolbar-btn highlight-btn"
onClick={onOpenActionSheet}
aria-label="Plus"
aria-label={t('editor.plus')}
>
<Sparkles size={18} className="text-amber-500 animate-pulse" />
</button>

View File

@@ -1,8 +1,8 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import { Maximize2 } from 'lucide-react'
import { Maximize2, Search, ChevronDown } from 'lucide-react'
interface Note {
id: string
@@ -35,8 +35,15 @@ interface NetworkGraphProps {
untitledLabel?: string
resetFocusLabel?: string
fitViewLabel?: string
legendFilterPlaceholder?: string
legendShowMoreLabel?: string
legendShowLessLabel?: string
/** Nombre de pastilles visibles avant « voir plus » (défaut 8) */
legendPreviewCount?: number
}
const DEFAULT_LEGEND_PREVIEW = 8
export function NetworkGraph({
notes,
clusters,
@@ -47,10 +54,45 @@ export function NetworkGraph({
untitledLabel = 'Untitled',
resetFocusLabel = 'Reset focus',
fitViewLabel = 'Fit view',
legendFilterPlaceholder = 'Filter themes…',
legendShowMoreLabel = 'Show {count} more',
legendShowLessLabel = 'Show less',
legendPreviewCount = DEFAULT_LEGEND_PREVIEW,
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const zoomRef = useRef<any>(null)
const [legendExpanded, setLegendExpanded] = useState(false)
const [legendFilter, setLegendFilter] = useState('')
const sortedClusters = useMemo(
() => [...clusters].sort((a, b) => b.noteIds.length - a.noteIds.length),
[clusters]
)
const filteredClusters = useMemo(() => {
const q = legendFilter.trim().toLowerCase()
if (!q) return sortedClusters
return sortedClusters.filter(c =>
(c.name ?? String(c.id)).toLowerCase().includes(q)
)
}, [sortedClusters, legendFilter])
const visibleLegendClusters = useMemo(() => {
if (legendExpanded || legendFilter.trim()) return filteredClusters
const preview = filteredClusters.slice(0, legendPreviewCount)
// Toujours garder le cluster sélectionné visible même hors top N
if (
selectedClusterId &&
!preview.some(c => String(c.id) === selectedClusterId)
) {
const selected = filteredClusters.find(c => String(c.id) === selectedClusterId)
if (selected) return [selected, ...preview.slice(0, Math.max(0, legendPreviewCount - 1))]
}
return preview
}, [filteredClusters, legendExpanded, legendFilter, legendPreviewCount, selectedClusterId])
const hiddenLegendCount = Math.max(0, filteredClusters.length - visibleLegendClusters.length)
useEffect(() => {
if (!svgRef.current || !containerRef.current) return
@@ -362,43 +404,88 @@ export function NetworkGraph({
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
{/* Pastilles de cluster — triées par taille, avec compteurs */}
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-1.5 max-w-[85%]">
{[...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<button
key={c.id}
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
isSelected
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
}`}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{c.noteIds.length}
</span>
</button>
)
})}
{selectedClusterId && (
<button
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
>
{resetFocusLabel}
</button>
{/* Pastilles de cluster — aperçu compact + filtre quand élargi */}
<div className="absolute top-6 start-6 z-10 flex flex-col gap-2 max-w-[min(420px,85%)]">
{(legendExpanded || sortedClusters.length > legendPreviewCount) && (
<div className="relative">
<Search
size={12}
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={legendFilter}
onChange={e => {
setLegendFilter(e.target.value)
if (e.target.value.trim()) setLegendExpanded(true)
}}
placeholder={legendFilterPlaceholder}
aria-label={legendFilterPlaceholder}
className="w-full max-w-[260px] bg-white/95 dark:bg-black/85 border border-border/40 rounded-full ps-8 pe-3 py-1.5 text-[10px] outline-none focus:ring-2 focus:ring-ochre/30 shadow-sm backdrop-blur-sm placeholder:text-concrete/70"
/>
</div>
)}
<div className="flex flex-wrap gap-1.5 max-h-[28vh] overflow-y-auto custom-scrollbar pe-1">
{visibleLegendClusters.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<button
key={c.id}
type="button"
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
isSelected
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
}`}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{c.noteIds.length}
</span>
</button>
)
})}
{selectedClusterId && (
<button
type="button"
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm cursor-pointer"
>
{resetFocusLabel}
</button>
)}
{!legendFilter.trim() && (hiddenLegendCount > 0 || legendExpanded) && filteredClusters.length > legendPreviewCount && (
<button
type="button"
onClick={() => {
setLegendExpanded(v => !v)
if (legendExpanded) setLegendFilter('')
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-full border border-dashed border-border/60 bg-white/80 dark:bg-black/70 text-concrete hover:text-ink text-[9px] font-bold uppercase tracking-wider shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronDown
size={11}
className={`transition-transform ${legendExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
{legendExpanded
? legendShowLessLabel
: legendShowMoreLabel.replace('{count}', String(hiddenLegendCount))}
</button>
)}
</div>
</div>
{/* Fit view button */}
<button
onClick={handleFitView}
className="absolute top-6 right-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
className="absolute top-6 end-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
aria-label={fitViewLabel}
>
<Maximize2 size={11} />

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