9 Commits

Author SHA1 Message Date
Antigravity
88a7d2ad0a fix(audit): nettoyage accès, garde-fous build, unification quotas
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m59s
CI / Deploy production (on server) (push) Successful in 24s
Semaine 1 — fuites et accès :
- /archive rendu accessible via sidebar (était invisible)
- Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions
- Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF
- 15 locales mises à jour

Semaine 2 — garde-fous :
- 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe)
- reactStrictMode: true (dev-only, zero impact prod)
- reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow
- auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse()

Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
2026-07-17 18:47:44 +00:00
Antigravity
e8ee53c815 fix(dashboard): audit Second Brain — pistes, erreur briefing, dark, crédits
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m16s
CI / Deploy production (on server) (push) Has been skipped
Corrige loadPaths sans argument après Echo/IA, bannière si briefing en échec,
checklist connexion inversée, skeletons dark, libellé usage = solde unique.
2026-07-16 21:08:48 +00:00
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
214 changed files with 48692 additions and 13934 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) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents…) — pas un slideshow landing seul ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
- **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 ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom.

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,8 +103,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<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) => (
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
@@ -120,69 +118,91 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</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>
{(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>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
@@ -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,59 +1,185 @@
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 className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
<h1 className="text-3xl font-bold text-foreground">
Administration
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
<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="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-gray-900 dark:text-white mb-4">
Recent Activity
<h2 className="text-lg font-semibold text-foreground mb-4">
Derniers utilisateurs
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
{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,27 +1,21 @@
'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();
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<LanguageProvider>
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<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">
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
</div>
</Link>
@@ -34,12 +28,27 @@ export default function AuthLayout({
<div className="w-8" />
</header>
);
}
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<LanguageProvider>
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<AuthHeader />
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2025 Memento Labs
© 2026 Memento
</p>
</div>
</main>

View File

@@ -1,44 +0,0 @@
import { Metadata } from 'next'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { ChatContainer } from '@/components/chat/chat-container'
import { getConversations } from '@/app/actions/chat-actions'
import { getSystemConfig } from '@/lib/config'
export const metadata: Metadata = {
title: 'Chat IA | Memento',
description: 'Discutez avec vos notes et vos agents IA',
}
export default async function ChatPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const userId = session.user.id
// Fetch initial data
const [conversations, notebooks, config] = await Promise.all([
getConversations(),
prisma.notebook.findMany({
where: { userId },
orderBy: { order: 'asc' }
}),
getSystemConfig(),
])
// Check if web search tools are configured
const webSearchAvailable = !!(
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
)
return (
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
<ChatContainer
initialConversations={conversations}
notebooks={notebooks}
webSearchAvailable={webSearchAvailable}
/>
</div>
)
}

View File

@@ -1,17 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
// D3 uses browser APIs — must be loaded client-side only
const NoteGraphView = dynamic(
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
{ ssr: false }
)
export default function GraphPage() {
return (
<div className="h-screen overflow-hidden">
<NoteGraphView />
</div>
)
}

View File

@@ -17,15 +17,19 @@ import {
Clock,
AlertCircle,
ChevronRight,
ChevronDown,
Database,
ArrowRight,
Menu,
Network,
List,
Search,
ArrowUpDown,
X,
} from 'lucide-react'
import { toast } from 'sonner'
import Link from 'next/link'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
import { createNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
const NetworkGraph = dynamic(
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
@@ -99,10 +103,28 @@ export default function InsightsPage() {
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */
const [expandedListClusterId, setExpandedListClusterId] = useState<string | null>(null)
const [dashboardFilter, setDashboardFilter] = useState('')
const [bridgesShowAll, setBridgesShowAll] = useState(false)
const [isolatedShowAll, setIsolatedShowAll] = useState(false)
const [suggestionsShowAll, setSuggestionsShowAll] = useState(false)
const [actingSuggestionKey, setActingSuggestionKey] = useState<string | null>(null)
const [dashboardSectionOpen, setDashboardSectionOpen] = useState({
recalc: false,
isolated: true,
bridges: true,
suggestions: false,
})
const [lastSyncTime, setLastSyncTime] = useState<string>('')
const peek = useNotePeek()
const prefersReducedMotion = useReducedMotion()
const DASHBOARD_PREVIEW = 8
const SUGGESTIONS_PREVIEW = 3
useEffect(() => {
loadInitialData()
}, [])
@@ -126,6 +148,43 @@ export default function InsightsPage() {
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const listClusters = useMemo(() => {
const q = listFilter.trim().toLowerCase()
const rows = clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id || cid === cluster.clusterId)
)
const name =
cluster.name ||
t('insightsView.clusterFallback', { index: cluster.clusterId })
const matchingNotes = q
? clusterNotes.filter(n =>
(n.title || '').toLowerCase().includes(q)
)
: clusterNotes
const nameMatches = !q || name.toLowerCase().includes(q)
return { cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }
})
const filtered = q
? rows.filter(r => r.nameMatches || r.matchingNotes.length > 0)
: rows
const sorted = [...filtered].sort((a, b) => {
if (listSort === 'alpha') return a.name.localeCompare(b.name, locale)
if (listSort === 'bridges') {
const d = b.clusterBridges.length - a.clusterBridges.length
return d !== 0 ? d : b.clusterNotes.length - a.clusterNotes.length
}
// size (default)
const d = b.clusterNotes.length - a.clusterNotes.length
return d !== 0 ? d : a.name.localeCompare(b.name, locale)
})
return sorted
}, [clusters, notes, bridgeNotes, listFilter, listSort, locale, t])
const bridgeList = useMemo(
() =>
bridgeNotes.map(b => ({
@@ -135,6 +194,53 @@ export default function InsightsPage() {
[bridgeNotes, t]
)
const filteredIsolatedClusters = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
if (!q) return isolatedClusters
return isolatedClusters.filter(c =>
(c.name || t('insightsView.clusterFallback', { index: c.clusterId }))
.toLowerCase()
.includes(q)
)
}, [isolatedClusters, dashboardFilter, t])
const filteredBridgeList = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
const list = [...bridgeList].sort((a, b) => b.bridgeScore - a.bridgeScore)
if (!q) return list
return list.filter(b => {
const titleMatch = b.title.toLowerCase().includes(q)
const clusterMatch = b.clustersConnected.some(cid => {
const cluster = clusters.find(c => c.id === String(cid))
const name =
cluster?.name || t('insightsView.clusterFallback', { index: cid })
return name.toLowerCase().includes(q)
})
return titleMatch || clusterMatch
})
}, [bridgeList, dashboardFilter, clusters, t])
const visibleIsolatedClusters = isolatedShowAll || dashboardFilter.trim()
? filteredIsolatedClusters
: filteredIsolatedClusters.slice(0, DASHBOARD_PREVIEW)
const visibleBridgeList = bridgesShowAll || dashboardFilter.trim()
? filteredBridgeList
: filteredBridgeList.slice(0, DASHBOARD_PREVIEW)
/** Affiche au plus 2 thèmes (pair de brokerage) ; le reste derrière +N. */
const getBridgeThemeSlice = useCallback((clusterIds: number[]) => {
const ranked = [...clusterIds].sort((a, b) => {
const sizeA = clusters.find(c => c.id === String(a))?.noteIds.length ?? 0
const sizeB = clusters.find(c => c.id === String(b))?.noteIds.length ?? 0
return sizeB - sizeA
})
return {
primary: ranked.slice(0, 2),
extraCount: Math.max(0, ranked.length - 2),
}
}, [clusters])
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
@@ -263,6 +369,55 @@ export default function InsightsPage() {
peek.open(noteId)
}
const visibleSuggestions = suggestionsShowAll
? suggestions.slice(0, 12)
: suggestions.slice(0, SUGGESTIONS_PREVIEW)
const handleCreateSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const html = `<p>${s.suggestedContent.replace(/</g, '&lt;')}</p><p><em>${s.justification.replace(/</g, '&lt;')}</em></p>`
const note = await createNote({ title: s.suggestedTitle, content: html })
if (!note) throw new Error('create failed')
emitNoteChange({ type: 'created', note })
await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
toast.success(t('insightsView.suggestions.created'))
router.push(`/home?openNote=${note.id}`)
} catch {
toast.error(t('insightsView.suggestions.createError'))
} finally {
setActingSuggestionKey(null)
}
}, [router, t])
const handleDismissSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const res = await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
if (!res.ok) throw new Error()
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
} catch {
toast.error(t('insightsView.suggestions.dismissError'))
} finally {
setActingSuggestionKey(null)
}
}, [t])
const isRtl = locale === 'fa' || locale === 'ar'
const motionConfig = prefersReducedMotion
@@ -298,9 +453,6 @@ export default function InsightsPage() {
</p>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
<span>{t('insightsView.semanticGraphLegend')}</span>
<Link href="/graph" className="inline-flex items-center gap-0.5 text-ochre hover:underline font-medium">
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
</Link>
</div>
</div>
</div>
@@ -436,7 +588,10 @@ export default function InsightsPage() {
<Network size={12} /> {t('insightsView.viewGraph')}
</button>
<button
onClick={() => setGraphMode('list')}
onClick={() => {
setGraphMode('list')
if (selectedClusterId) setExpandedListClusterId(selectedClusterId)
}}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
graphMode === 'list'
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
@@ -468,53 +623,145 @@ export default function InsightsPage() {
untitledLabel={t('insightsView.unknownNote')}
resetFocusLabel={t('insightsView.resetFocus')}
fitViewLabel={t('insightsView.fitGraphView')}
legendFilterPlaceholder={t('insightsView.legendFilterPlaceholder')}
legendShowMoreLabel={t('insightsView.legendShowMore')}
legendShowLessLabel={t('insightsView.legendShowLess')}
/>
</div>
)}
{/* Vue liste accessible (a11y fallback) */}
{/* Vue liste accessible (accordion + filtre + tri) */}
{graphMode === 'list' && (
<div
className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-4"
className="flex-1 min-h-0 flex flex-col gap-3"
role="region"
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
>
{clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id)
)
<div className="shrink-0 flex flex-col sm:flex-row gap-2">
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={listFilter}
onChange={e => setListFilter(e.target.value)}
placeholder={t('insightsView.listFilterPlaceholder')}
aria-label={t('insightsView.listFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70"
/>
</div>
<label className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white dark:bg-zinc-800/60 border border-border/40 text-[10px] font-bold uppercase tracking-wider text-concrete">
<ArrowUpDown size={12} aria-hidden />
<span className="sr-only">{t('insightsView.listSortLabel')}</span>
<select
value={listSort}
onChange={e => setListSort(e.target.value as 'size' | 'alpha' | 'bridges')}
className="bg-transparent outline-none cursor-pointer text-ink dark:text-dark-ink font-bold uppercase tracking-wider"
aria-label={t('insightsView.listSortLabel')}
>
<option value="size">{t('insightsView.listSortSize')}</option>
<option value="bridges">{t('insightsView.listSortBridges')}</option>
<option value="alpha">{t('insightsView.listSortAlpha')}</option>
</select>
</label>
</div>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-2">
{listClusters.length === 0 ? (
<p className="text-xs text-concrete text-center py-10 px-4">
{t('insightsView.listFilterEmpty')}
</p>
) : (
listClusters.map(({ cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }) => {
const isExpanded = expandedListClusterId === cluster.id
const displayNotes = !listFilter.trim() || nameMatches
? clusterNotes
: matchingNotes
return (
<div
key={cluster.id}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30"
className="rounded-xl bg-white dark:bg-zinc-800 border border-border/30 overflow-hidden"
>
<div className="flex items-center gap-2 mb-3">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cluster.color }} />
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">
{cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId })}
<button
type="button"
onClick={() => {
setExpandedListClusterId(prev =>
prev === cluster.id ? null : cluster.id
)
setSelectedClusterId(cluster.id)
}}
aria-expanded={isExpanded}
className="w-full flex items-center gap-2 p-3.5 text-start hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ochre/50 focus-visible:outline-none"
>
{isExpanded ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cluster.color }}
aria-hidden
/>
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
{name}
</h3>
<span className="text-[9px] text-concrete ml-auto shrink-0">
<span className="text-[9px] text-concrete shrink-0 tabular-nums">
{clusterNotes.length} {t('insightsView.graphNotesLabel')}
{clusterBridges.length > 0 && ` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{clusterBridges.length > 0 &&
` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{listFilter.trim() && matchingNotes.length > 0 && matchingNotes.length < clusterNotes.length && (
<span className="text-ochre">
{` · ${matchingNotes.length} ${t('insightsView.listFilterMatch')}`}
</span>
</div>
<ul className="space-y-1">
{clusterNotes.map(note => (
)}
</span>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="notes"
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<ul className="px-3 pb-3 space-y-0.5 border-t border-border/20 pt-2">
{displayNotes.length === 0 ? (
<li className="px-2.5 py-2 text-[11px] text-concrete">
{t('insightsView.clusters.emptyCluster')}
</li>
) : (
displayNotes.map(note => (
<li key={note.id}>
<button
type="button"
onClick={() => handleNoteClick(note.id)}
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
className="w-full text-start px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronRight size={11} className="text-concrete shrink-0" />
<span className="truncate">{note.title || t('insightsView.unknownNote')}</span>
<ChevronRight size={11} className="text-concrete shrink-0" aria-hidden />
<span className="truncate">
{note.title || t('insightsView.unknownNote')}
</span>
</button>
</li>
))}
))
)}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
})
)}
</div>
</div>
)}
</div>
@@ -525,7 +772,33 @@ export default function InsightsPage() {
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
}`}
>
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-8">
{/* Filtre panneau droit (sticky) */}
<div className="sticky top-0 z-20 -mx-1 px-1 pb-2 bg-[#fcfbfa]/95 dark:bg-[#0D0D0D]/90 backdrop-blur-md">
<div className="relative">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={dashboardFilter}
onChange={e => {
setDashboardFilter(e.target.value)
if (e.target.value.trim()) {
setBridgesShowAll(true)
setIsolatedShowAll(true)
setDashboardSectionOpen(s => ({ ...s, bridges: true, isolated: true }))
}
}}
placeholder={t('insightsView.dashboardFilterPlaceholder')}
aria-label={t('insightsView.dashboardFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70 shadow-sm"
/>
</div>
</div>
{/* Avertissement d'obsolescence (stale banner) */}
{isStale && !isCalculating && (
@@ -618,7 +891,7 @@ export default function InsightsPage() {
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.graphNotesLabel')}
{t('insightsView.stats.themesSubtitle')}
</p>
</div>
</div>
@@ -634,26 +907,48 @@ export default function InsightsPage() {
{bridgeNotes.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.bridgeCount')}
{t('insightsView.stats.bridgesSubtitle')}
</p>
</div>
</div>
</div>
{/* ③ Système de Recalcul */}
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
<div className="flex items-center justify-between gap-4">
{/* ③ Système de Recalcul (replié par défaut) */}
<section className="rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, recalc: !s.recalc }))}
aria-expanded={dashboardSectionOpen.recalc}
className="w-full flex items-center justify-between gap-4 p-5 text-start hover:bg-black/[0.02] dark:hover:bg-white/[0.03] transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Sliders size={15} className="text-ochre" />
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
{t('insightsView.recalcSystem.title')}
</h4>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
<CheckCircle2 size={11} /> {t('insightsView.recalcSystem.statusSynced')}
</span>
{dashboardSectionOpen.recalc ? (
<ChevronDown size={14} className="text-concrete" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete" aria-hidden />
)}
</div>
<div className="grid grid-cols-2 gap-4 pt-1">
</button>
<AnimatePresence initial={false}>
{dashboardSectionOpen.recalc && (
<motion.div
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 space-y-4 border-t border-border/10 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-[9px] text-concrete block">{t('insightsView.recalcSystem.scheduledCron')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
@@ -677,15 +972,12 @@ export default function InsightsPage() {
})
: '—'}
</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ml-3">
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ms-3">
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
</span>
</div>
<p className="text-[8px] text-concrete italic block leading-relaxed">
{t('insightsView.tipClusters')}
</p>
<button
type="button"
onClick={() => void handleReindexEmbeddings()}
@@ -699,61 +991,122 @@ export default function InsightsPage() {
)}
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
<span className="text-[8px] text-concrete italic block leading-relaxed">
{t('insightsView.tipClustersAction')}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
{/* ④ Clusters Isolés */}
<section className="space-y-4">
<div className="flex items-center justify-between gap-4 px-1">
<div className="flex items-center gap-2">
<AlertCircle size={15} className="text-rose-400" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
{t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, isolated: !s.isolated }))}
aria-expanded={dashboardSectionOpen.isolated}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer group"
>
<div className="flex items-center gap-2 min-w-0">
<AlertCircle size={15} className="text-rose-400 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.isolatedClusters.title', { count: filteredIsolatedClusters.length })}
</h3>
</div>
<span className="text-[9px] text-concrete italic">{t('insightsView.tipIsolatedAction')}</span>
</div>
{dashboardSectionOpen.isolated ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.isolated && (
<div className="space-y-2">
{isolatedClusters.map(c => (
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipIsolatedAction')}
</p>
{visibleIsolatedClusters.map(c => (
<motion.div
key={c.id}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => setSelectedClusterId(c.id)}
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink truncate">
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
</span>
</div>
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10 shrink-0">
{t('insightsView.isolatedClusters.badge')}
</span>
</motion.div>
))}
{isolatedClusters.length === 0 && (
{!dashboardFilter.trim() && filteredIsolatedClusters.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setIsolatedShowAll(v => !v)}
className="w-full py-2 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{isolatedShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredIsolatedClusters.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredIsolatedClusters.length === 0 && (
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
{t('insightsView.isolatedClusters.empty')}
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.isolatedClusters.empty')}
</div>
)}
</div>
)}
</section>
{/* ⑤ Notes-Ponts Influentes */}
<section className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, bridges: !s.bridges }))}
aria-expanded={dashboardSectionOpen.bridges}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Zap size={16} className="text-ochre shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.bridgeNotes.title')}
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({filteredBridgeList.length})
</span>
</h3>
</div>
<p className="text-[10px] text-concrete italic px-1 -mt-2 leading-relaxed">{t('insightsView.tipBridgeNotes')}</p>
{dashboardSectionOpen.bridges ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.bridges && (
<div className="space-y-3">
{bridgeList.map(bridge => (
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipBridgeNotes')}
</p>
{visibleBridgeList.map(bridge => {
const { primary, extraCount } = getBridgeThemeSlice(bridge.clustersConnected)
const themeA = clusters.find(c => c.id === String(primary[0]))
const themeB = clusters.find(c => c.id === String(primary[1]))
const nameA =
themeA?.name ||
t('insightsView.clusterFallback', { index: primary[0] ?? 0 })
const nameB =
themeB?.name ||
(primary[1] !== undefined
? t('insightsView.clusterFallback', { index: primary[1] })
: null)
return (
<motion.div
key={bridge.noteId}
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
@@ -763,98 +1116,212 @@ export default function InsightsPage() {
role="button"
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
>
<div className="flex items-center justify-between mb-2 gap-4">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink line-clamp-2 flex-1 group-hover:text-ochre transition-colors">
{bridge.title}
</h4>
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0">
{t('insightsView.bridgeNotes.score', {
<span
className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0"
title={t('insightsView.bridgeNotes.scoreHint')}
>
{t('insightsView.bridgeNotes.affinity', {
score: (bridge.bridgeScore * 100).toFixed(0),
})}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5">
{bridge.clustersConnected.map(cid => {
const cluster = clusters.find(c => c.id === String(cid))
return (
<div
key={cid}
{nameB ? (
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(cid))
setSelectedClusterId(String(primary[0]))
}}
className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors cursor-pointer"
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeA?.color || '#cbd5e1' }}
/>
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
{cluster?.name || t('insightsView.clusterFallback', { index: cid })}
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameA}
</span>
</button>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(primary[1]))
}}
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeB?.color || '#cbd5e1' }}
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameB}
</span>
</button>
{extraCount > 0 && (
<span className="text-[9px] text-concrete font-bold uppercase tracking-wider">
{t('insightsView.bridgeNotes.moreThemes', { count: extraCount })}
</span>
)}
</div>
) : (
<p className="text-[10px] text-concrete italic">
{t('insightsView.bridgeNotes.needsResync')}
</p>
)}
</motion.div>
)
})}
</div>
</motion.div>
))}
{bridgeList.length === 0 && !isCalculating && (
{!dashboardFilter.trim() && filteredBridgeList.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setBridgesShowAll(v => !v)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{bridgesShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredBridgeList.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredBridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
{t('insightsView.bridgeNotes.empty')}
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.bridgeNotes.empty')}
</div>
)}
</div>
)}
</section>
{/* ⑥ Opportunités de Connexion */}
<section className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
{/* ⑥ Opportunités de Connexion (link prediction — top near-miss pairs) */}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, suggestions: !s.suggestions }))}
aria-expanded={dashboardSectionOpen.suggestions}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Lightbulb size={16} className="text-indigo-500 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.suggestions.title')}
{suggestions.length > 0 && (
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({Math.min(suggestions.length, 12)})
</span>
)}
</h3>
</div>
<p className="text-[10px] text-concrete italic px-1 -mt-2 leading-relaxed">{t('insightsView.tipSuggestions')}</p>
<div className="space-y-4">
{suggestions.map(s => (
{dashboardSectionOpen.suggestions ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.suggestions && (
<div className="space-y-3">
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipSuggestions')}
</p>
{visibleSuggestions.map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
const busy = actingSuggestionKey === key
return (
<div
key={`${s.clusterAId}-${s.clusterBId}`}
className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 via-transparent to-transparent border border-indigo-500/10 hover:border-indigo-500/20 transition-all shadow-sm"
key={key}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-indigo-500/15 hover:border-indigo-500/30 transition-all shadow-sm"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2 shrink-0">
<div className="w-5 h-5 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[9px] text-white font-bold">
A
</div>
<div className="w-5 h-5 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[9px] text-white font-bold">
B
</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
{t('insightsView.suggestions.bridging', {
clusterA: s.clusterAName,
clusterB: s.clusterBName,
})}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span
className="w-2 h-2 rounded-full shrink-0 bg-indigo-500"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterAName}
</span>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<span
className="w-2 h-2 rounded-full shrink-0 bg-ochre"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterBName}
</span>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
<button
type="button"
disabled={busy}
onClick={() => void handleDismissSuggestion(s)}
className="p-1 rounded-md text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50 cursor-pointer"
aria-label={t('insightsView.suggestions.dismiss')}
title={t('insightsView.suggestions.dismiss')}
>
<X size={14} />
</button>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5 line-clamp-2">
{s.suggestedTitle}
</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">
<p className="text-[11px] text-muted-ink leading-relaxed line-clamp-2 mb-3">
{s.suggestedContent}
</p>
<div className="p-3.5 bg-white/60 dark:bg-zinc-800 rounded-xl border border-border/20 text-[10.5px] italic text-concrete flex gap-2">
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
<span>{s.justification}</span>
<button
type="button"
disabled={busy}
onClick={() => void handleCreateSuggestion(s)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-ink text-paper dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-widest hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer"
>
{busy ? (
<RefreshCw size={12} className="animate-spin" />
) : (
<Lightbulb size={12} />
)}
{t('insightsView.suggestions.createNote')}
</button>
</div>
</div>
))}
)
})}
{!suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(true)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowMore', {
count: Math.min(suggestions.length, 12) - SUGGESTIONS_PREVIEW,
})}
</button>
)}
{suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(false)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowLess')}
</button>
)}
{isCalculating && (
<div className="animate-pulse space-y-4">
<div className="animate-pulse space-y-3">
{[1, 2].map(i => (
<div
key={i}
className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10"
className="h-24 bg-indigo-500/5 rounded-xl border border-indigo-500/10"
/>
))}
</div>
@@ -865,6 +1332,7 @@ export default function InsightsPage() {
</div>
)}
</div>
)}
</section>
</div>

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,156 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/lib/i18n';
export default function SupportPage() {
const { t } = useLanguage();
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">
{t('support.description')}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('support.buyMeACoffee')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.donationDescription')}
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.donateOnKofi')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.kofiDescription')}
</p>
</CardContent>
</Card>
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
{t('support.sponsorOnGithub')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.sponsorDescription')}
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.sponsorOnGithub')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.githubDescription')}
</p>
</CardContent>
</Card>
</div>
<Card className="mb-10">
<CardHeader>
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
<li> Funds development of new features</li>
<li>📚 Improves documentation</li>
<li>🌍 Keeps Memento 100% open-source</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
<li>💎 $100/month - Platinum: Custom features, consulting</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>💡 {t('support.transparency')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
{t('support.transparencyDescription')}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>{t('support.hostingServers')}</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>{t('support.domainSSL')}</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>{t('support.aiApiCosts')}</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">{t('support.totalExpenses')}</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Any amount beyond these costs goes directly into improving Memento
and funding new features. Thank you for your support! 💚
</p>
</CardContent>
</Card>
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
{t('support.starGithub')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 {t('support.reportBug')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 {t('support.contributeCode')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 {t('support.shareTwitter')}
</a>
</Button>
</div>
</div>
</div>
);
}

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}>
<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]) =>
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
}
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, 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

@@ -2,7 +2,6 @@
import { chatService } from '@/lib/ai/services'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
/**
@@ -38,7 +37,6 @@ export async function sendChatMessage(
try {
const result = await chatService.chat(message, conversationId, notebookId)
revalidatePath('/chat')
return { success: true, ...result }
} catch (error: any) {
console.error('[ChatAction] Error:', 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,37 +42,101 @@ 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({
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]
const isEn = language === 'English'
@@ -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
@@ -18,6 +19,7 @@ export async function POST(request: NextRequest) {
{ status: 401 }
)
}
const userId = session.user.id
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
@@ -59,11 +61,16 @@ export async function POST(request: NextRequest) {
)
}
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
const suggestions = await withAiQuota(
userId,
'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
session.user.id,
language
userId,
language,
),
{ lane: 'tags' },
)
if (!suggestions) {
@@ -79,8 +86,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(
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,
mode === 'title' ? 'title' : 'description',
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)
const fusedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(fusionPrompt),
{ lane: 'chat' },
)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate fusion' },
{ status: 500 }
)
}
} 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 {
@@ -9,6 +10,7 @@ export async function POST(req: NextRequest) {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
@@ -21,7 +23,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 +32,25 @@ export async function POST(req: NextRequest) {
})
}
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
const suggestedNotebook = await withAiQuota(
userId,
'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
session.user.id,
userId,
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,10 +62,8 @@ 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')
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
@@ -74,7 +73,6 @@ export async function POST(req: NextRequest) {
}
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)
const isPersian = /[\u0600-\u06FF]/.test(content)

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' as const,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
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,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
if (checkoutSession.client_secret) {
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
clientSecret: embedded.client_secret,
sessionId: embedded.id,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
return NextResponse.json({ url: checkoutSession.url });
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
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 });
} 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(
const summary = await withSessionAiQuota(
billingOwnerId,
session.user.id,
isGuestActor,
'brainstorm_enrich',
async () => {
const { result } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
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

@@ -1,195 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
const STOPWORDS = new Set([
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
'from','is','are','was','were','be','been','have','has','had','do','does',
'did','will','would','could','should','may','might','this','that','these',
'those','it','its','they','them','their','he','she','we','you','not','no',
'so','if','as','up','out','about','also','just','can','all','any','get',
])
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
}
function extractKeywords(text: string): Set<string> {
return new Set(
stripHtml(text)
.toLowerCase()
.split(/[\s\p{P}]+/u)
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const w of a) if (b.has(w)) intersection++
return intersection / (a.size + b.size - intersection)
}
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
// GET /api/graph — connexions automatiques à 3 niveaux
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const { searchParams } = new URL(request.url)
const notebookId = searchParams.get('notebookId') || undefined
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
select: {
id: true, title: true, content: true, notebookId: true, createdAt: true,
labelRelations: { select: { id: true } },
notebook: { select: { id: true, name: true } },
},
take: 500,
orderBy: { updatedAt: 'desc' },
})
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
const ids = notes.map(n => n.id)
// Query NoteLink manually created relationships
const noteLinks = await (prisma as any).noteLink.findMany({
where: {
sourceNoteId: { in: ids },
targetNoteId: { in: ids }
},
select: {
sourceNoteId: true,
targetNoteId: true,
contextSnippet: true
}
})
// Query MemoryEchoInsight semantic relationships
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
note1Id: { in: ids },
note2Id: { in: ids }
},
select: {
note1Id: true,
note2Id: true,
similarityScore: true,
insight: true
}
})
// Pré-calcul
const keywordsMap = new Map<string, Set<string>>()
const labelMap = new Map<string, Set<string>>()
for (const note of notes) {
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
}
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
explicit_link: 5,
semantic_echo: 4,
title_mention: 3,
shared_label: 2,
jaccard: 1,
}
const edgeMap = new Map<string, GraphEdge>()
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
const key = a < b ? `${a}--${b}` : `${b}--${a}`
const ex = edgeMap.get(key)
if (!ex) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
} else {
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
}
}
}
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
for (const noteA of notes) {
const title = (noteA.title ?? '').trim().toLowerCase()
if (title.length < 3) continue
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
for (const noteB of notes) {
if (noteA.id === noteB.id) continue
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
}
}
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const la = labelMap.get(ids[i])!
const lb = labelMap.get(ids[j])!
const shared = [...la].filter(l => lb.has(l)).length
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
}
}
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
if (notes.length <= 500) {
for (let i = 0; i < ids.length; i++) {
const kwI = keywordsMap.get(ids[i])!
const candidates: { j: number; score: number }[] = []
for (let j = i + 1; j < ids.length; j++) {
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
if (score >= 0.12) candidates.push({ j, score })
}
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
}
}
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
for (const link of noteLinks) {
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
}
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
for (const echo of echoInsights) {
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
}
const degreeMap = new Map<string, number>()
for (const e of edgeMap.values()) {
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
}
const nodes = notes.map(n => ({
id: n.id,
title: n.title || 'Sans titre',
notebookId: n.notebookId,
createdAt: n.createdAt,
degree: degreeMap.get(n.id) ?? 0,
}))
// Build clusters (notebooks)
const notebookMap = new Map<string, string>()
for (const n of notes) {
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
}
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
}

View File

@@ -1,84 +0,0 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
function extractWikilinks(content: string): { title: string; snippet: string }[] {
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
/**
* POST /api/graph/sync-all
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
* Call once to populate the NoteLink table from existing notes.
*/
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Get all non-trashed notes with content
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, content: { not: '' } },
select: { id: true, content: true, notebookId: true },
})
let totalLinks = 0
for (const note of notes) {
if (!note.content.includes('[[')) continue
const wikilinks = extractWikilinks(note.content)
if (wikilinks.length === 0) continue
for (const { title, snippet } of wikilinks) {
const targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Skip stubs in batch sync — we only link existing notes
continue
}
if (targetNote.id === note.id) continue
try {
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet },
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
})
totalLinks++
} catch {
// ignore duplicate constraint errors
}
}
}
return NextResponse.json({ synced: notes.length, links: totalLinks })
}

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 ? '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

@@ -1,61 +0,0 @@
'use client'
import { useState } from 'react'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
export default function TestTitleSuggestionsPage() {
const [content, setContent] = useState('')
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
content,
enabled: true // Always enabled for testing
})
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Test Title Suggestions</h1>
<div style={{ marginBottom: '20px' }}>
<label>Content (need 50+ words):</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ width: '100%', height: '200px', marginTop: '10px' }}
placeholder="Type at least 50 words here..."
/>
</div>
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
<p><strong>Word count:</strong> {wordCount} / 50</p>
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
</div>
<div>
<h2>Suggestions ({suggestions.length}):</h2>
{suggestions.length > 0 ? (
<ul>
{suggestions.map((s, i) => (
<li key={i}>
<strong>{s.title}</strong> (confidence: {s.confidence}%)
{s.reasoning && <p> {s.reasoning}</p>}
</li>
))}
</ul>
) : (
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
)}
</div>
<div style={{ marginTop: '20px' }}>
<button onClick={() => {
setContent('word '.repeat(50))
}}>
Fill with 50 words (test)
</button>
</div>
</div>
)
}

View File

@@ -23,15 +23,12 @@ export const authConfig = {
nextUrl.pathname.startsWith('/settings') ||
nextUrl.pathname.startsWith('/lab') ||
nextUrl.pathname.startsWith('/agents') ||
nextUrl.pathname.startsWith('/chat') ||
nextUrl.pathname.startsWith('/canvas') ||
nextUrl.pathname.startsWith('/notebooks') ||
nextUrl.pathname.startsWith('/note/') ||
nextUrl.pathname.startsWith('/brainstorm') ||
nextUrl.pathname.startsWith('/insights') ||
nextUrl.pathname.startsWith('/graph') ||
nextUrl.pathname.startsWith('/revision') ||
nextUrl.pathname.startsWith('/support');
nextUrl.pathname.startsWith('/revision');
const isAdminPage = nextUrl.pathname.startsWith('/admin');
const isPublicPage = nextUrl.pathname === '/' ||
nextUrl.pathname === '/login' ||

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' },
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,222 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ChatSidebar } from './chat-sidebar'
import { ChatMessages } from './chat-messages'
import { ChatInput } from './chat-input'
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
import { toast } from 'sonner'
import type { UIMessage } from 'ai'
import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps {
initialConversations: any[]
notebooks: any[]
webSearchAvailable?: boolean
}
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation()
const skipHistoryLoad = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
})).current
const {
messages,
sendMessage,
status,
setMessages,
stop,
} = useChat({
transport,
onError: (error) => {
toast.error(error.message || t('chat.assistantError'))
},
})
const isLoading = status === 'submitted' || status === 'streaming'
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
// Timeout warning: show toast if response takes > 30s
useEffect(() => {
if (!isLoading) return
const timer = setTimeout(() => {
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
}, 30000)
return () => clearTimeout(timer)
}, [isLoading, t])
// Sync historyMessages after each completed streaming response
// so the display doesn't revert to stale history.
// Also refresh sidebar so new conversation appears.
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
setHistoryMessages([...messages])
refreshConversations()
}
}, [status, messages, refreshConversations])
// Load conversation details when the user selects a different conversation
useEffect(() => {
// Skip if we just created the conversation — useChat already has the messages
if (skipHistoryLoad.current) {
skipHistoryLoad.current = false
return
}
if (currentId) {
const loadMessages = async () => {
setIsLoadingHistory(true)
try {
const details = await getConversationDetails(currentId)
if (details) {
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
id: m.id || `hist-${i}`,
role: m.role as 'user' | 'assistant',
parts: [{ type: 'text' as const, text: m.content }],
}))
setHistoryMessages(loaded)
setMessages(loaded)
}
} catch (error) {
toast.error(t('chat.loadError'))
} finally {
setIsLoadingHistory(false)
}
}
loadMessages()
} else {
setMessages([])
setHistoryMessages([])
}
}, [currentId])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)
}
// If no active conversation, create one BEFORE streaming
let convId = currentId
if (!convId) {
try {
const result = await createConversation(content, notebookId || selectedNotebook)
convId = result.id
// Tell the useEffect to skip — we don't want to load an empty conversation
skipHistoryLoad.current = true
setCurrentId(convId)
setHistoryMessages([])
setConversations((prev) => [
{ id: result.id, title: result.title, updatedAt: new Date() },
...prev,
])
} catch {
toast.error(t('chat.createError'))
return
}
}
try {
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
} catch (error) {
console.error('Chat send error:', error)
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
const handleNewChat = () => {
setCurrentId(null)
setMessages([])
setHistoryMessages([])
setSelectedNotebook(undefined)
setWebSearchEnabled(false)
}
const handleDeleteConversation = async (id: string) => {
try {
await deleteConversation(id)
if (currentId === id) {
handleNewChat()
}
await refreshConversations()
} catch {
toast.error(t('chat.deleteError'))
}
}
// During streaming or if useChat has more messages than history, prefer useChat
const displayMessages = isLoading || messages.length > historyMessages.length
? messages
: historyMessages
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [displayMessages])
return (
<div className="flex-1 flex overflow-hidden bg-background">
<ChatSidebar
conversations={conversations}
currentId={currentId}
onSelect={setCurrentId}
onNew={handleNewChat}
onDelete={handleDeleteConversation}
/>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}
isLoading={isLoading}
onStop={stop}
notebooks={notebooks}
currentNotebookId={selectedNotebook || null}
webSearchEnabled={webSearchEnabled}
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
webSearchAvailable={webSearchAvailable}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,159 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
interface ChatInputProps {
onSend: (message: string, notebookId?: string) => void
isLoading?: boolean
onStop?: () => void
notebooks: any[]
currentNotebookId?: string | null
webSearchEnabled?: boolean
onToggleWebSearch?: () => void
webSearchAvailable?: boolean
}
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
const { t } = useLanguage()
const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (currentNotebookId) {
setSelectedNotebook(currentNotebookId)
}
}, [currentNotebookId])
const handleSend = () => {
if (!input.trim() || isLoading) return
onSend(input, selectedNotebook)
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
ref={textareaRef}
placeholder={t('chat.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
/>
{/* Bottom Actions Bar */}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */}
<div className="flex items-center gap-2">
<Select
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
{(() => {
const Icon = getNotebookIcon(nb.icon)
return <Icon className="w-3.5 h-3.5" />
})()} {nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebook && (
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
{t('chat.active')}
</Badge>
)}
{webSearchAvailable && (
<button
type="button"
onClick={onToggleWebSearch}
className={cn(
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
)}
>
<Globe className="h-3.5 w-3.5" />
{webSearchEnabled && t('chat.webSearch')}
</button>
)}
</div>
{/* Send / Stop Button */}
{isLoading && onStop ? (
<Button
onClick={onStop}
size="icon"
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
disabled={!input.trim() || isLoading}
onClick={handleSend}
size="icon"
className={cn(
"rounded-full h-8 w-8 transition-all duration-200",
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
)}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
<div className="text-center mt-3">
<span className="text-[11px] text-muted-foreground/60 w-full block">
{t('chat.disclaimer')}
</span>
</div>
</div>
)
}

View File

@@ -1,84 +0,0 @@
'use client'
import { User, Bot, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { useLanguage } from '@/lib/i18n'
interface ChatMessagesProps {
messages: any[]
isLoading?: boolean
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const { t } = useLanguage()
return (
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
<Bot className="h-12 w-12 text-primary opacity-60" />
</div>
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
{t('chat.welcome')}
</p>
</div>
)}
{messages.map((msg, index) => {
const content = getMessageContent(msg)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
return (
<div
key={msg.id || index}
className={cn(
"flex w-full px-4 md:px-0 py-6 my-2 group",
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
) : (
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div dir="auto" className="flex-1 overflow-hidden pt-1">
{content ? (
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : isLastAssistant ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
</div>
) : null}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,127 +0,0 @@
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ChatSidebarProps {
conversations: any[]
currentId?: string | null
onSelect: (id: string) => void
onNew: () => void
onDelete?: (id: string) => void
}
export function ChatSidebar({
conversations,
currentId,
onSelect,
onNew,
onDelete,
}: ChatSidebarProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const confirmDelete = (id: string) => {
setPendingDelete(id)
}
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setPendingDelete(null)
}
const executeDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
setPendingDelete(null)
if (onDelete) {
await onDelete(id)
}
}
return (
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
<div className="p-4 border-bottom">
<Button
onClick={onNew}
className="w-full justify-start gap-2 shadow-sm"
variant="outline"
>
<Plus className="h-4 w-4" />
{t('chat.newConversation')}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{t('chat.noHistory')}
</div>
) : (
conversations.map((chat) => (
<div
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"relative cursor-pointer rounded-lg transition-all group",
currentId === chat.id
? "bg-primary/10 text-primary dark:bg-primary/20"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
<div className="p-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-medium pr-6">
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{/* Delete button — visible on hover or when confirming */}
{pendingDelete !== chat.id && (
<button
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Inline confirmation banner */}
{pendingDelete === chat.id && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
<button
onClick={(e) => executeDelete(e, chat.id)}
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
>
{t('chat.yes')}
</button>
<button
onClick={cancelDelete}
className="p-0.5 hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))
)}
</div>
</div>
)
}

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,6 +1114,43 @@ export function ContextualAIChat({
</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.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">
@@ -1115,6 +1161,19 @@ export function ContextualAIChat({
<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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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 dark:bg-zinc-950/40 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>
)}
</>
)
}

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