Compare commits
9 Commits
6e13b6d207
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a7d2ad0a | ||
|
|
e8ee53c815 | ||
|
|
a77f3ffb6e | ||
|
|
556a0b2f3f | ||
|
|
704fed1191 | ||
|
|
45297da333 | ||
|
|
4fe31ebc99 | ||
|
|
30da592ba2 | ||
|
|
d38a99586b |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -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 qu’une 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+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
|
||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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, '<')}</p><p><em>${s.justification.replace(/</g, '<')}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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('Readwise déconnecté')
|
||||
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 Calendar déconnecté')
|
||||
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'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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
22
memento-note/app/(main)/settings/mcp/mcp-settings-header.tsx
Normal file
22
memento-note/app/(main)/settings/mcp/mcp-settings-header.tsx
Normal 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') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
/**
|
||||
* Plus d’animation d’entré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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 l’application 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
@@ -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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
21
memento-note/app/api/agents/suggestions/[id]/accept/route.ts
Normal file
21
memento-note/app/api/agents/suggestions/[id]/accept/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
30
memento-note/app/api/agents/suggestions/route.ts
Normal file
30
memento-note/app/api/agents/suggestions/route.ts
Normal 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(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
204
memento-note/app/api/briefing/paths/route.ts
Normal file
204
memento-note/app/api/briefing/paths/route.ts
Normal 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(/ /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)
|
||||
}
|
||||
240
memento-note/app/api/briefing/route.ts
Normal file
240
memento-note/app/api/briefing/route.ts
Normal 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(/ /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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
99
memento-note/app/api/briefing/sentiment/route.ts
Normal file
99
memento-note/app/api/briefing/sentiment/route.ts
Normal 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(/ /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' })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
41
memento-note/app/api/cron/agent-suggestions/route.ts
Normal file
41
memento-note/app/api/cron/agent-suggestions/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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++
|
||||
|
||||
49
memento-note/app/api/cron/gmail/route.ts
Normal file
49
memento-note/app/api/cron/gmail/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
49
memento-note/app/api/dashboard/layout/route.ts
Normal file
49
memento-note/app/api/dashboard/layout/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
||||
'did','will','would','could','should','may','might','this','that','these',
|
||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
||||
])
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
||||
}
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
stripHtml(text)
|
||||
.toLowerCase()
|
||||
.split(/[\s\p{P}]+/u)
|
||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
||||
)
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let intersection = 0
|
||||
for (const w of a) if (b.has(w)) intersection++
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const notebookId = searchParams.get('notebookId') || undefined
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
||||
select: {
|
||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
const ids = notes.map(n => n.id)
|
||||
|
||||
// Query NoteLink manually created relationships
|
||||
const noteLinks = await (prisma as any).noteLink.findMany({
|
||||
where: {
|
||||
sourceNoteId: { in: ids },
|
||||
targetNoteId: { in: ids }
|
||||
},
|
||||
select: {
|
||||
sourceNoteId: true,
|
||||
targetNoteId: true,
|
||||
contextSnippet: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query MemoryEchoInsight semantic relationships
|
||||
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
|
||||
where: {
|
||||
userId,
|
||||
dismissed: false,
|
||||
note1Id: { in: ids },
|
||||
note2Id: { in: ids }
|
||||
},
|
||||
select: {
|
||||
note1Id: true,
|
||||
note2Id: true,
|
||||
similarityScore: true,
|
||||
insight: true
|
||||
}
|
||||
})
|
||||
|
||||
// Pré-calcul
|
||||
const keywordsMap = new Map<string, Set<string>>()
|
||||
const labelMap = new Map<string, Set<string>>()
|
||||
for (const note of notes) {
|
||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
||||
}
|
||||
|
||||
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
|
||||
explicit_link: 5,
|
||||
semantic_echo: 4,
|
||||
title_mention: 3,
|
||||
shared_label: 2,
|
||||
jaccard: 1,
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>()
|
||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
||||
const ex = edgeMap.get(key)
|
||||
if (!ex) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
} else {
|
||||
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
|
||||
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
|
||||
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
||||
for (const noteA of notes) {
|
||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||
if (title.length < 3) continue
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
||||
for (const noteB of notes) {
|
||||
if (noteA.id === noteB.id) continue
|
||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const la = labelMap.get(ids[i])!
|
||||
const lb = labelMap.get(ids[j])!
|
||||
const shared = [...la].filter(l => lb.has(l)).length
|
||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
||||
if (notes.length <= 500) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const kwI = keywordsMap.get(ids[i])!
|
||||
const candidates: { j: number; score: number }[] = []
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
||||
if (score >= 0.12) candidates.push({ j, score })
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
|
||||
for (const link of noteLinks) {
|
||||
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
|
||||
}
|
||||
|
||||
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
|
||||
for (const echo of echoInsights) {
|
||||
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
for (const e of edgeMap.values()) {
|
||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
degree: degreeMap.get(n.id) ?? 0,
|
||||
}))
|
||||
|
||||
// Build clusters (notebooks)
|
||||
const notebookMap = new Map<string, string>()
|
||||
for (const n of notes) {
|
||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
||||
}
|
||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
||||
|
||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/graph/sync-all
|
||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
||||
* Call once to populate the NoteLink table from existing notes.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// Get all non-trashed notes with content
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, content: { not: '' } },
|
||||
select: { id: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
let totalLinks = 0
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.content.includes('[[')) continue
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
if (wikilinks.length === 0) continue
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
const targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Skip stubs in batch sync — we only link existing notes
|
||||
continue
|
||||
}
|
||||
|
||||
if (targetNote.id === note.id) continue
|
||||
|
||||
try {
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet },
|
||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
||||
})
|
||||
totalLinks++
|
||||
} catch {
|
||||
// ignore duplicate constraint errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
||||
}
|
||||
38
memento-note/app/api/integrations/gmail/callback/route.ts
Normal file
38
memento-note/app/api/integrations/gmail/callback/route.ts
Normal 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`)
|
||||
}
|
||||
67
memento-note/app/api/integrations/gmail/route.ts
Normal file
67
memento-note/app/api/integrations/gmail/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 n’a 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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
|
||||
export default function TestTitleSuggestionsPage() {
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
|
||||
content,
|
||||
enabled: true // Always enabled for testing
|
||||
})
|
||||
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>Test Title Suggestions</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>Content (need 50+ words):</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ width: '100%', height: '200px', marginTop: '10px' }}
|
||||
placeholder="Type at least 50 words here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
|
||||
<p><strong>Word count:</strong> {wordCount} / 50</p>
|
||||
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
|
||||
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Suggestions ({suggestions.length}):</h2>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul>
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<strong>{s.title}</strong> (confidence: {s.confidence}%)
|
||||
{s.reasoning && <p>→ {s.reasoning}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => {
|
||||
setContent('word '.repeat(50))
|
||||
}}>
|
||||
Fill with 50 words (test)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,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' ||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { ChatSidebar } from './chat-sidebar'
|
||||
import { ChatMessages } from './chat-messages'
|
||||
import { ChatInput } from './chat-input'
|
||||
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
|
||||
import { toast } from 'sonner'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatContainerProps {
|
||||
initialConversations: any[]
|
||||
notebooks: any[]
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [conversations, setConversations] = useState(initialConversations)
|
||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Prevents the useEffect from loading an empty conversation
|
||||
// when we just created one via createConversation()
|
||||
const skipHistoryLoad = useRef(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
})).current
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
toast.error(error.message || t('chat.assistantError'))
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const updated = await getConversations()
|
||||
setConversations(updated)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Timeout warning: show toast if response takes > 30s
|
||||
useEffect(() => {
|
||||
if (!isLoading) return
|
||||
const timer = setTimeout(() => {
|
||||
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
||||
}, 30000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isLoading, t])
|
||||
|
||||
// Sync historyMessages after each completed streaming response
|
||||
// so the display doesn't revert to stale history.
|
||||
// Also refresh sidebar so new conversation appears.
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
setHistoryMessages([...messages])
|
||||
refreshConversations()
|
||||
}
|
||||
}, [status, messages, refreshConversations])
|
||||
|
||||
// Load conversation details when the user selects a different conversation
|
||||
useEffect(() => {
|
||||
// Skip if we just created the conversation — useChat already has the messages
|
||||
if (skipHistoryLoad.current) {
|
||||
skipHistoryLoad.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
const loadMessages = async () => {
|
||||
setIsLoadingHistory(true)
|
||||
try {
|
||||
const details = await getConversationDetails(currentId)
|
||||
if (details) {
|
||||
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
|
||||
id: m.id || `hist-${i}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
parts: [{ type: 'text' as const, text: m.content }],
|
||||
}))
|
||||
setHistoryMessages(loaded)
|
||||
setMessages(loaded)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('chat.loadError'))
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
loadMessages()
|
||||
} else {
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
}
|
||||
|
||||
}, [currentId])
|
||||
|
||||
const handleSendMessage = async (content: string, notebookId?: string) => {
|
||||
if (notebookId) {
|
||||
setSelectedNotebook(notebookId)
|
||||
}
|
||||
|
||||
// If no active conversation, create one BEFORE streaming
|
||||
let convId = currentId
|
||||
if (!convId) {
|
||||
try {
|
||||
const result = await createConversation(content, notebookId || selectedNotebook)
|
||||
convId = result.id
|
||||
// Tell the useEffect to skip — we don't want to load an empty conversation
|
||||
skipHistoryLoad.current = true
|
||||
setCurrentId(convId)
|
||||
setHistoryMessages([])
|
||||
setConversations((prev) => [
|
||||
{ id: result.id, title: result.title, updatedAt: new Date() },
|
||||
...prev,
|
||||
])
|
||||
} catch {
|
||||
toast.error(t('chat.createError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
{ text: content },
|
||||
{
|
||||
body: {
|
||||
conversationId: convId,
|
||||
notebookId: notebookId || selectedNotebook || undefined,
|
||||
language,
|
||||
webSearch: webSearchEnabled,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Chat send error:', error)
|
||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = () => {
|
||||
setCurrentId(null)
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
setWebSearchEnabled(false)
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
try {
|
||||
await deleteConversation(id)
|
||||
if (currentId === id) {
|
||||
handleNewChat()
|
||||
}
|
||||
await refreshConversations()
|
||||
} catch {
|
||||
toast.error(t('chat.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
// During streaming or if useChat has more messages than history, prefer useChat
|
||||
const displayMessages = isLoading || messages.length > historyMessages.length
|
||||
? messages
|
||||
: historyMessages
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [displayMessages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-background">
|
||||
<ChatSidebar
|
||||
conversations={conversations}
|
||||
currentId={currentId}
|
||||
onSelect={setCurrentId}
|
||||
onNew={handleNewChat}
|
||||
onDelete={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={stop}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, notebookId?: string) => void
|
||||
isLoading?: boolean
|
||||
onStop?: () => void
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
webSearchEnabled?: boolean
|
||||
onToggleWebSearch?: () => void
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentNotebookId) {
|
||||
setSelectedNotebook(currentNotebookId)
|
||||
}
|
||||
}, [currentNotebookId])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
onSend(input, selectedNotebook)
|
||||
setInput('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
|
||||
|
||||
{/* Input Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={t('chat.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{/* Bottom Actions Bar */}
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
{/* Context Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
|
||||
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(nb.icon)
|
||||
return <Icon className="w-3.5 h-3.5" />
|
||||
})()} {nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebook && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
||||
{t('chat.active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{webSearchAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleWebSearch}
|
||||
className={cn(
|
||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
||||
webSearchEnabled
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
||||
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{webSearchEnabled && t('chat.webSearch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send / Stop Button */}
|
||||
{isLoading && onStop ? (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
size="icon"
|
||||
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-[11px] text-muted-foreground/60 w-full block">
|
||||
{t('chat.disclaimer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { User, Bot, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: any[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function getMessageContent(msg: any): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p: any) => p.type === 'text')
|
||||
.map((p: any) => p.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
|
||||
<Bot className="h-12 w-12 text-primary opacity-60" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
|
||||
{t('chat.welcome')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const content = getMessageContent(msg)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id || index}
|
||||
className={cn(
|
||||
"flex w-full px-4 md:px-0 py-6 my-2 group",
|
||||
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
|
||||
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
|
||||
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div dir="auto" className="flex-1 overflow-hidden pt-1">
|
||||
{content ? (
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : isLastAssistant ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
conversations: any[]
|
||||
currentId?: string | null
|
||||
onSelect: (id: string) => void
|
||||
onNew: () => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
conversations,
|
||||
currentId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
}: ChatSidebarProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
||||
|
||||
const confirmDelete = (id: string) => {
|
||||
setPendingDelete(id)
|
||||
}
|
||||
|
||||
const cancelDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
}
|
||||
|
||||
const executeDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
if (onDelete) {
|
||||
await onDelete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
|
||||
<div className="p-4 border-bottom">
|
||||
<Button
|
||||
onClick={onNew}
|
||||
className="w-full justify-start gap-2 shadow-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('chat.newConversation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{t('chat.noHistory')}
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg transition-all group",
|
||||
currentId === chat.id
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20"
|
||||
: "hover:bg-muted/50 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-medium pr-6">
|
||||
{chat.title || t('chat.untitled')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
|
||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover or when confirming */}
|
||||
{pendingDelete !== chat.id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
|
||||
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline confirmation banner */}
|
||||
{pendingDelete === chat.id && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
|
||||
<button
|
||||
onClick={(e) => executeDelete(e, chat.id)}
|
||||
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
{t('chat.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="p-0.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
126
memento-note/components/dashboard-action-strip.tsx
Normal file
126
memento-note/components/dashboard-action-strip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
memento-note/components/dashboard-agent-carousel.tsx
Normal file
160
memento-note/components/dashboard-agent-carousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
413
memento-note/components/dashboard-catalog-widgets.tsx
Normal file
413
memento-note/components/dashboard-catalog-widgets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
memento-note/components/dashboard-mind-orbit.tsx
Normal file
139
memento-note/components/dashboard-mind-orbit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
memento-note/components/dashboard-next-paths.tsx
Normal file
165
memento-note/components/dashboard-next-paths.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
memento-note/components/dashboard-path-widgets.tsx
Normal file
258
memento-note/components/dashboard-path-widgets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
memento-note/components/dashboard-resume-hero.tsx
Normal file
154
memento-note/components/dashboard-resume-hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
memento-note/components/dashboard-sentiment-chip.tsx
Normal file
113
memento-note/components/dashboard-sentiment-chip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1118
memento-note/components/dashboard-view.tsx
Normal file
1118
memento-note/components/dashboard-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
415
memento-note/components/dashboard-widget-grid.tsx
Normal file
415
memento-note/components/dashboard-widget-grid.tsx
Normal 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
Reference in New Issue
Block a user