Compare commits
40 Commits
1d17fe2f9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe31ebc99 | ||
|
|
30da592ba2 | ||
|
|
d38a99586b | ||
|
|
6e13b6d207 | ||
|
|
4d95234e32 | ||
|
|
eb6cda9d24 | ||
|
|
f7da22d409 | ||
|
|
3d783ac9e2 | ||
|
|
fbeea7027c | ||
|
|
1f5dc6af09 | ||
|
|
a84c7e80d6 | ||
|
|
a9e43d7594 | ||
|
|
df2b9c2c7b | ||
|
|
d586048b52 | ||
|
|
5821e2c96f | ||
|
|
261eee2953 | ||
|
|
45fc178589 | ||
|
|
6536180e91 | ||
|
|
ee9d6650a2 | ||
|
|
c8bed7f138 | ||
|
|
a2d8d90e3d | ||
|
|
8b454b9cf4 | ||
|
|
36b5724b72 | ||
|
|
ce1a13801f | ||
|
|
c271754cfa | ||
|
|
03a3fb7411 | ||
|
|
e72ca26f97 | ||
|
|
cf5d6a202b | ||
|
|
06c771ee9d | ||
|
|
a7e3646ae6 | ||
|
|
6b44d35c40 | ||
|
|
72f9798e97 | ||
|
|
e48152e294 | ||
|
|
d48312dfc2 | ||
|
|
f9d79365f3 | ||
|
|
58fe5eb54f | ||
|
|
73a3d206b0 | ||
|
|
03dcde44ca | ||
|
|
10101e5918 | ||
|
|
56ce662d38 |
@@ -59,13 +59,14 @@
|
|||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/375b2e07-202f-4e8e-9e80-718bbdf88005.jsonl": 1781973640324,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/375b2e07-202f-4e8e-9e80-718bbdf88005.jsonl": 1781973640324,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/6cba0291-51b3-42d9-939c-2e95d01128f8.jsonl": 1781975383642,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/6cba0291-51b3-42d9-939c-2e95d01128f8.jsonl": 1781975383642,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/d6546245-4e3f-47dd-bc3e-39e1af726138.jsonl": 1781973598054,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/d6546245-4e3f-47dd-bc3e-39e1af726138.jsonl": 1781973598054,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/ec2b194c-c67e-4089-a434-6daff69ca69d.jsonl": 1782633068860,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/ec2b194c-c67e-4089-a434-6daff69ca69d.jsonl": 1783196585431,
|
||||||
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/2bb8ea12-29cb-4456-a5ee-af86d7a427a3.jsonl": 1783196591937,
|
||||||
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/372b1e4e-cc60-4117-b3c1-48af0dbc38d9.jsonl": 1782059598659,
|
||||||
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/8ad24313-529f-41f7-96b3-dc6314e94c72.jsonl": 1782633223580,
|
||||||
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/993325c9-a748-4183-9b2d-866cc0d73338.jsonl": 1782056882673,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/99e20a50-1e7b-4ed8-931a-5c4c7a65ea4d.jsonl": 1782037965469,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/99e20a50-1e7b-4ed8-931a-5c4c7a65ea4d.jsonl": 1782037965469,
|
||||||
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/e483aeb3-3b38-4f7f-9ad8-d79f362c102d.jsonl": 1782058095940,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/f0ad176d-04d7-4d9a-82b8-65273acd313a/subagents/96507ccc-6150-4260-a55c-94abd2b57441.jsonl": 1778946698447,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/f0ad176d-04d7-4d9a-82b8-65273acd313a/subagents/96507ccc-6150-4260-a55c-94abd2b57441.jsonl": 1778946698447,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1780001507987,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1780001507987,
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/subagents/a5601ff1-7934-4872-acd8-266e416c3680.jsonl": 1779998536313,
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/subagents/a5601ff1-7934-4872-acd8-266e416c3680.jsonl": 1779998536313
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/993325c9-a748-4183-9b2d-866cc0d73338.jsonl": 1782056882673,
|
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/e483aeb3-3b38-4f7f-9ad8-d79f362c102d.jsonl": 1782058095940,
|
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/372b1e4e-cc60-4117-b3c1-48af0dbc38d9.jsonl": 1782059598659,
|
|
||||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/8ad24313-529f-41f7-96b3-dc6314e94c72.jsonl": 1782633077601
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lastRunAtMs": 1782633053032,
|
"lastRunAtMs": 1783196565768,
|
||||||
"turnsSinceLastRun": 8,
|
"turnsSinceLastRun": 5,
|
||||||
"lastTranscriptMtimeMs": 1782633052959.9294,
|
"lastTranscriptMtimeMs": 1783196565691.0437,
|
||||||
"lastProcessedGenerationId": "6d690dcb-cba6-47d2-9361-43956be8667e",
|
"lastProcessedGenerationId": "7ac52bd5-5714-460a-9c3b-e4b21907d6d7",
|
||||||
"trialStartedAtMs": null
|
"trialStartedAtMs": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,3 +78,6 @@ TELEGRAM_CHAT_ID=<var>
|
|||||||
# Monitoring
|
# Monitoring
|
||||||
METRICS_TOKEN=<secret>
|
METRICS_TOKEN=<secret>
|
||||||
GRAFANA_ADMIN_PASSWORD=<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,12 +174,13 @@ jobs:
|
|||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
|
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
|
||||||
|
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
|
||||||
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
|
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
|
||||||
run: |
|
run: |
|
||||||
ENV_FILE="/opt/memento/.env.docker"
|
ENV_FILE="/opt/memento/.env.docker"
|
||||||
touch "$ENV_FILE"
|
touch "$ENV_FILE"
|
||||||
sed -i 's/\r$//' "$ENV_FILE"
|
sed -i 's/\r$//' "$ENV_FILE"
|
||||||
echo "## AUTO-MANAGED BY CI — do not edit manually ##" > "$ENV_FILE"
|
|
||||||
upsert() {
|
upsert() {
|
||||||
local key="$1" val="$2"
|
local key="$1" val="$2"
|
||||||
[ -z "$val" ] && return
|
[ -z "$val" ] && return
|
||||||
@@ -233,19 +234,11 @@ jobs:
|
|||||||
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
|
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
|
||||||
upsert METRICS_TOKEN "$METRICS_TOKEN"
|
upsert METRICS_TOKEN "$METRICS_TOKEN"
|
||||||
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
||||||
|
upsert GRAFANA_BIND "$GRAFANA_BIND"
|
||||||
|
upsert GRAFANA_URL "$GRAFANA_URL"
|
||||||
upsert MCP_API_KEY "$MCP_API_KEY"
|
upsert MCP_API_KEY "$MCP_API_KEY"
|
||||||
# Write metrics token file for Prometheus (same secret)
|
# 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
|
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
|
||||||
# Sanity-check: abort if a critical var is missing
|
|
||||||
for required in NEXTAUTH_URL NEXTAUTH_SECRET AUTH_GOOGLE_ID AUTH_GOOGLE_SECRET \
|
|
||||||
AI_PROVIDER_TAGS AI_MODEL_TAGS AI_PROVIDER_EMBEDDING AI_MODEL_EMBEDDING \
|
|
||||||
AI_PROVIDER_CHAT AI_MODEL_CHAT MCP_SERVER_URL; do
|
|
||||||
grep -q "^${required}=" "$ENV_FILE" || {
|
|
||||||
echo "ERROR: required var $required missing in $ENV_FILE — check Gitea vars/secrets"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
done
|
|
||||||
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"
|
|
||||||
|
|
||||||
- name: Deploy on 192.168.1.190
|
- name: Deploy on 192.168.1.190
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -63,12 +63,13 @@ jobs:
|
|||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
|
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
|
||||||
|
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
|
||||||
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
|
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
|
||||||
run: |
|
run: |
|
||||||
ENV_FILE="/opt/memento/.env.docker"
|
ENV_FILE="/opt/memento/.env.docker"
|
||||||
touch "$ENV_FILE"
|
touch "$ENV_FILE"
|
||||||
sed -i 's/\r$//' "$ENV_FILE"
|
sed -i 's/\r$//' "$ENV_FILE"
|
||||||
echo "## AUTO-MANAGED BY CI — do not edit manually ##" > "$ENV_FILE"
|
|
||||||
upsert() {
|
upsert() {
|
||||||
local key="$1" val="$2"
|
local key="$1" val="$2"
|
||||||
[ -z "$val" ] && return
|
[ -z "$val" ] && return
|
||||||
@@ -122,17 +123,10 @@ jobs:
|
|||||||
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
|
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
|
||||||
upsert METRICS_TOKEN "$METRICS_TOKEN"
|
upsert METRICS_TOKEN "$METRICS_TOKEN"
|
||||||
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
||||||
|
upsert GRAFANA_BIND "$GRAFANA_BIND"
|
||||||
|
upsert GRAFANA_URL "$GRAFANA_URL"
|
||||||
upsert MCP_API_KEY "$MCP_API_KEY"
|
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
|
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
|
||||||
# Sanity-check: abort if a critical var is missing
|
|
||||||
for required in NEXTAUTH_URL NEXTAUTH_SECRET AUTH_GOOGLE_ID AUTH_GOOGLE_SECRET \
|
|
||||||
AI_PROVIDER_TAGS AI_MODEL_TAGS AI_PROVIDER_EMBEDDING AI_MODEL_EMBEDDING \
|
|
||||||
AI_PROVIDER_CHAT AI_MODEL_CHAT MCP_SERVER_URL; do
|
|
||||||
grep -q "^${required}=" "$ENV_FILE" || {
|
|
||||||
echo "ERROR: required var $required missing in $ENV_FILE — check Gitea vars/secrets"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
done
|
|
||||||
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"
|
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"
|
||||||
|
|
||||||
- name: Deploy (full build, no CI artifact)
|
- name: Deploy (full build, no CI artifact)
|
||||||
|
|||||||
24
AGENTS.md
24
AGENTS.md
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## Learned User Preferences
|
## Learned User Preferences
|
||||||
|
|
||||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
|
- 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.
|
- 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.**
|
- 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 ; `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) ; 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** ; 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.
|
||||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. **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` ; **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.
|
- 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).
|
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
|
||||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
||||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande.
|
- 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`.
|
- **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** — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
||||||
|
|
||||||
## Learned Workspace Facts
|
## 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).
|
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
|
||||||
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
|
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
|
||||||
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA et config Stripe métier en base, effet ~60 s ; guide `memento-note/docs/admin-billing-quotas-guide.md`.
|
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA 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).
|
- **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`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
||||||
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
||||||
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
||||||
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
||||||
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Living Blocks partiellement livrées** : `data-id`, Smart Paste, nœud `liveBlock`, détacher/supprimer, peek split — **US-NEXTGEN-EDITOR** en cours (`docs/story-nextgen-editor.md`, **US-TEMPORAL reporté**) ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx`, **`/insights` ≠ `/graph`**) ; 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.
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ services:
|
|||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
memory: 128M
|
memory: 128M
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ function setCachedKey(keyHash, data) {
|
|||||||
keyCache.set(keyHash, { data, timestamp: Date.now() });
|
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.
|
* Generate a new API key.
|
||||||
* @param {import('@prisma/client').PrismaClient} prisma
|
* @param {import('@prisma/client').PrismaClient} prisma
|
||||||
@@ -111,6 +133,9 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
const info = JSON.parse(entry.value);
|
const info = JSON.parse(entry.value);
|
||||||
if (info.keyHash !== keyHash || !info.active) return null;
|
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();
|
info.lastUsedAt = new Date().toISOString();
|
||||||
prisma.systemConfig.update({
|
prisma.systemConfig.update({
|
||||||
where: { key: configKey },
|
where: { key: configKey },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Tabs } from 'expo-router'
|
import { Tabs } from 'expo-router'
|
||||||
import { BookOpen, Search, Home, User, GraduationCap } from 'lucide-react-native'
|
import { BookOpen, Search, Home, User, GraduationCap } from 'lucide-react-native'
|
||||||
import { C } from '@/lib/theme'
|
import { C } from '@/lib/theme'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
return (
|
return (
|
||||||
@@ -13,11 +14,11 @@ export default function TabsLayout() {
|
|||||||
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginTop: -2 },
|
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginTop: -2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen name="home" options={{ title: 'Accueil', tabBarIcon: ({ color, size }) => <Home size={size} color={color} /> }} />
|
<Tabs.Screen name="home" options={{ title: t('tab.home'), tabBarIcon: ({ color, size }) => <Home size={size} color={color} /> }} />
|
||||||
<Tabs.Screen name="notebooks" options={{ title: 'Carnets', tabBarIcon: ({ color, size }) => <BookOpen size={size} color={color} /> }} />
|
<Tabs.Screen name="notebooks" options={{ title: t('tab.notebooks'), tabBarIcon: ({ color, size }) => <BookOpen size={size} color={color} /> }} />
|
||||||
<Tabs.Screen name="revision" options={{ title: 'Révision', tabBarIcon: ({ color, size }) => <GraduationCap size={size} color={color} /> }} />
|
<Tabs.Screen name="revision" options={{ title: t('tab.revision'), tabBarIcon: ({ color, size }) => <GraduationCap size={size} color={color} /> }} />
|
||||||
<Tabs.Screen name="search" options={{ title: 'Recherche', tabBarIcon: ({ color, size }) => <Search size={size} color={color} /> }} />
|
<Tabs.Screen name="search" options={{ title: t('tab.search'), tabBarIcon: ({ color, size }) => <Search size={size} color={color} /> }} />
|
||||||
<Tabs.Screen name="profile" options={{ title: 'Profil', tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
<Tabs.Screen name="profile" options={{ title: t('tab.profile'), tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ export default function NoteScreen() {
|
|||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (!note) return
|
if (!note) return
|
||||||
setEditTitle(note.title)
|
setEditTitle(note.title)
|
||||||
setEditContent(htmlToPlainText(note.content ?? ''))
|
|
||||||
setEditMode(true)
|
setEditMode(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +167,10 @@ export default function NoteScreen() {
|
|||||||
try {
|
try {
|
||||||
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ title: editTitle.trim(), content: editContent }),
|
body: JSON.stringify({ title: editTitle.trim() }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Erreur de sauvegarde')
|
if (!res.ok) throw new Error('Erreur de sauvegarde')
|
||||||
setNote((prev) => prev ? { ...prev, title: editTitle.trim(), content: editContent } : prev)
|
setNote((prev) => prev ? { ...prev, title: editTitle.trim() } : prev)
|
||||||
setEditMode(false)
|
setEditMode(false)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Erreur', e.message)
|
Alert.alert('Erreur', e.message)
|
||||||
@@ -188,7 +187,12 @@ export default function NoteScreen() {
|
|||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (!note) return
|
if (!note) return
|
||||||
await Share.share({ title: note.title, message: `${note.title}\nhttps://memento-note.com` })
|
const publicUrl = note.publicSlug ? `https://memento-note.com/p/${note.publicSlug}` : null
|
||||||
|
const plain = note.content?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || ''
|
||||||
|
const message = publicUrl
|
||||||
|
? `${note.title}\n\n${plain}...\n\nLire: ${publicUrl}`
|
||||||
|
: `${note.title}\n\n${plain}${plain.length === 200 ? '...' : ''}`
|
||||||
|
await Share.share({ title: note.title, message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -257,20 +261,21 @@ export default function NoteScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mode édition */}
|
{/* Mode édition — titre uniquement, contenu preserve HTML */}
|
||||||
{note && editMode && (
|
{note && editMode && (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<TextInput value={editTitle} onChangeText={setEditTitle} style={s.editTitle} placeholder="Titre…" placeholderTextColor={C.border} />
|
<TextInput value={editTitle} onChangeText={setEditTitle} style={s.editTitle} placeholder="Titre…" placeholderTextColor={C.border} autoFocus />
|
||||||
<View style={s.editDivider} />
|
<View style={s.editDivider} />
|
||||||
<TextInput value={editContent} onChangeText={setEditContent} style={s.editContent} placeholder="Contenu…" placeholderTextColor={C.concrete} multiline textAlignVertical="top" scrollEnabled />
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||||
|
<Text style={{ fontSize: 14, color: C.concrete, textAlign: 'center', lineHeight: 22 }}>
|
||||||
|
Le contenu enrichi ne peut pas être édité depuis mobile.{`\n\n`}Utilisez la version web pour modifier le texte.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
{/* Barre outils */}
|
{/* Barre outils */}
|
||||||
<View style={s.toolbar}>
|
<View style={s.toolbar}>
|
||||||
<MicButton state={audioState} onPress={handleMic} />
|
<TouchableOpacity onPress={handleSave} disabled={saving} style={[s.aiBtn, { flex: 1, justifyContent: 'center' }]} activeOpacity={0.8}>
|
||||||
{audioState === 'recording'
|
<Text style={s.aiBtnText}>{saving ? 'Sauvegarde…' : 'Enregistrer le titre'}</Text>
|
||||||
? <Text style={s.recordHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
</TouchableOpacity>
|
||||||
: <TouchableOpacity onPress={() => setAiSheetOpen(true)} disabled={!editContent.trim()} style={[s.aiBtn, !editContent.trim() && { opacity: 0.35 }]} activeOpacity={0.8}>
|
|
||||||
<Text style={s.aiBtnText}>✨ Améliorer avec l'IA</Text>
|
|
||||||
</TouchableOpacity>}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function TitleSheet({ visible, onClose, content, onSelect }: Props) {
|
|||||||
if (visible && content.trim()) {
|
if (visible && content.trim()) {
|
||||||
fetchTitles()
|
fetchTitles()
|
||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible, content])
|
||||||
|
|
||||||
const fetchTitles = async () => {
|
const fetchTitles = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|||||||
176
memento-mobile/lib/i18n.ts
Normal file
176
memento-mobile/lib/i18n.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { getLocales } from 'expo-localization'
|
||||||
|
import { I18nManager } from 'react-native'
|
||||||
|
|
||||||
|
export type MobileLang = 'fr' | 'en' | 'fa'
|
||||||
|
|
||||||
|
const translations: Record<MobileLang, Record<string, string>> = {
|
||||||
|
fr: {
|
||||||
|
'tab.home': 'Accueil',
|
||||||
|
'tab.notebooks': 'Carnets',
|
||||||
|
'tab.revision': 'Révision',
|
||||||
|
'tab.search': 'Recherche',
|
||||||
|
'tab.profile': 'Profil',
|
||||||
|
'common.error': 'Erreur',
|
||||||
|
'common.loading': 'Chargement…',
|
||||||
|
'common.save': 'Enregistrer',
|
||||||
|
'common.delete': 'Supprimer',
|
||||||
|
'common.cancel': 'Annuler',
|
||||||
|
'common.search': 'Rechercher',
|
||||||
|
'common.settings': 'Paramètres',
|
||||||
|
'common.logout': 'Déconnexion',
|
||||||
|
'note.title': 'Titre…',
|
||||||
|
'note.contentPlaceholder': 'Contenu…',
|
||||||
|
'note.deleteTitle': 'Supprimer la note',
|
||||||
|
'note.deleteConfirm': 'Voulez-vous vraiment supprimer cette note ?',
|
||||||
|
'note.saveError': 'Erreur de sauvegarde',
|
||||||
|
'note.deleteError': 'Impossible de supprimer la note',
|
||||||
|
'note.dayNote': 'Note du jour',
|
||||||
|
'note.dayNoteError': 'Impossible de charger la note du jour.',
|
||||||
|
'note.editTitleOnly': "Le contenu enrichi ne peut pas être édité depuis mobile. Utilisez la version web pour modifier le texte.",
|
||||||
|
'note.saveTitle': 'Enregistrer le titre',
|
||||||
|
'note.saving': 'Sauvegarde…',
|
||||||
|
'auth.loginFailed': 'Connexion échouée',
|
||||||
|
'auth.googleFailed': 'Connexion Google échouée',
|
||||||
|
'auth.serverError': 'Erreur serveur',
|
||||||
|
'auth.unauthorized': 'Non autorisé',
|
||||||
|
'auth.emailPlaceholder': 'Email',
|
||||||
|
'auth.passwordPlaceholder': 'Mot de passe',
|
||||||
|
'auth.loginButton': 'Se connecter',
|
||||||
|
'auth.orGoogle': 'ou',
|
||||||
|
'revision.title': 'Révision',
|
||||||
|
'revision.empty': 'Aucune carte à réviser',
|
||||||
|
'revision.loadingError': 'Erreur de chargement',
|
||||||
|
'revision.difficult': 'Difficile',
|
||||||
|
'revision.hard': 'Dur',
|
||||||
|
'revision.good': 'Bien',
|
||||||
|
'revision.easy': 'Facile',
|
||||||
|
'profile.title': 'Profil',
|
||||||
|
'search.placeholder': 'Rechercher des notes…',
|
||||||
|
'search.noResults': 'Aucun résultat',
|
||||||
|
'ai.improve': "✨ Améliorer avec l'IA",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
'tab.home': 'Home',
|
||||||
|
'tab.notebooks': 'Notebooks',
|
||||||
|
'tab.revision': 'Review',
|
||||||
|
'tab.search': 'Search',
|
||||||
|
'tab.profile': 'Profile',
|
||||||
|
'common.error': 'Error',
|
||||||
|
'common.loading': 'Loading…',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.delete': 'Delete',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.search': 'Search',
|
||||||
|
'common.settings': 'Settings',
|
||||||
|
'common.logout': 'Log out',
|
||||||
|
'note.title': 'Title…',
|
||||||
|
'note.contentPlaceholder': 'Content…',
|
||||||
|
'note.deleteTitle': 'Delete note',
|
||||||
|
'note.deleteConfirm': 'Are you sure you want to delete this note?',
|
||||||
|
'note.saveError': 'Save error',
|
||||||
|
'note.deleteError': 'Could not delete the note',
|
||||||
|
'note.dayNote': 'Note of the day',
|
||||||
|
'note.dayNoteError': 'Could not load the note of the day.',
|
||||||
|
'note.editTitleOnly': 'Rich content cannot be edited from mobile. Use the web version to modify the text.',
|
||||||
|
'note.saveTitle': 'Save title',
|
||||||
|
'note.saving': 'Saving…',
|
||||||
|
'auth.loginFailed': 'Login failed',
|
||||||
|
'auth.googleFailed': 'Google sign-in failed',
|
||||||
|
'auth.serverError': 'Server error',
|
||||||
|
'auth.unauthorized': 'Unauthorized',
|
||||||
|
'auth.emailPlaceholder': 'Email',
|
||||||
|
'auth.passwordPlaceholder': 'Password',
|
||||||
|
'auth.loginButton': 'Sign in',
|
||||||
|
'auth.orGoogle': 'or',
|
||||||
|
'revision.title': 'Review',
|
||||||
|
'revision.empty': 'No cards to review',
|
||||||
|
'revision.loadingError': 'Loading error',
|
||||||
|
'revision.difficult': 'Hard',
|
||||||
|
'revision.hard': 'Difficult',
|
||||||
|
'revision.good': 'Good',
|
||||||
|
'revision.easy': 'Easy',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'search.placeholder': 'Search notes…',
|
||||||
|
'search.noResults': 'No results',
|
||||||
|
'ai.improve': '✨ Improve with AI',
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
'tab.home': 'خانه',
|
||||||
|
'tab.notebooks': 'دفترچهها',
|
||||||
|
'tab.revision': 'مرور',
|
||||||
|
'tab.search': 'جستجو',
|
||||||
|
'tab.profile': 'پروفایل',
|
||||||
|
'common.error': 'خطا',
|
||||||
|
'common.loading': 'در حال بارگذاری…',
|
||||||
|
'common.save': 'ذخیره',
|
||||||
|
'common.delete': 'حذف',
|
||||||
|
'common.cancel': 'انصراف',
|
||||||
|
'common.search': 'جستجو',
|
||||||
|
'common.settings': 'تنظیمات',
|
||||||
|
'common.logout': 'خروج',
|
||||||
|
'note.title': 'عنوان…',
|
||||||
|
'note.contentPlaceholder': 'محتوا…',
|
||||||
|
'note.deleteTitle': 'حذف یادداشت',
|
||||||
|
'note.deleteConfirm': 'آیا از حذف این یادداشت مطمئن هستید؟',
|
||||||
|
'note.saveError': 'خطای ذخیرهسازی',
|
||||||
|
'note.deleteError': 'حذف یادداشت امکانپذیر نیست',
|
||||||
|
'note.dayNote': 'یادداشت روز',
|
||||||
|
'note.dayNoteError': 'بارگذاری یادداشت روز امکانپذیر نیست.',
|
||||||
|
'note.editTitleOnly': 'محتوای غنی از موبایل قابل ویرایش نیست. برای ویرایش متن از نسخه وب استفاده کنید.',
|
||||||
|
'note.saveTitle': 'ذخیره عنوان',
|
||||||
|
'note.saving': 'در حال ذخیره…',
|
||||||
|
'auth.loginFailed': 'ورود ناموفق',
|
||||||
|
'auth.googleFailed': 'ورود با گوگل ناموفق',
|
||||||
|
'auth.serverError': 'خطای سرور',
|
||||||
|
'auth.unauthorized': 'غیر مجاز',
|
||||||
|
'auth.emailPlaceholder': 'ایمیل',
|
||||||
|
'auth.passwordPlaceholder': 'رمز عبور',
|
||||||
|
'auth.loginButton': 'ورود',
|
||||||
|
'auth.orGoogle': 'یا',
|
||||||
|
'revision.title': 'مرور',
|
||||||
|
'revision.empty': 'کارت مروری موجود نیست',
|
||||||
|
'revision.loadingError': 'خطای بارگذاری',
|
||||||
|
'revision.difficult': 'سخت',
|
||||||
|
'revision.hard': 'دشوار',
|
||||||
|
'revision.good': 'خوب',
|
||||||
|
'revision.easy': 'آسان',
|
||||||
|
'profile.title': 'پروفایل',
|
||||||
|
'search.placeholder': 'جستجوی یادداشت…',
|
||||||
|
'search.noResults': 'نتیجهای یافت نشد',
|
||||||
|
'ai.improve': '✨ بهبود با هوش مصنوعی',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const RTL_LANGS: MobileLang[] = ['fa', 'ar']
|
||||||
|
|
||||||
|
function detectLang(): MobileLang {
|
||||||
|
try {
|
||||||
|
const locales = getLocales()
|
||||||
|
const lang = locales[0]?.languageCode ?? 'fr'
|
||||||
|
if (lang === 'fa') return 'fa'
|
||||||
|
if (lang === 'en') return 'en'
|
||||||
|
return 'fr'
|
||||||
|
} catch {
|
||||||
|
return 'fr'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLang: MobileLang = detectLang()
|
||||||
|
|
||||||
|
export function setLang(lang: MobileLang) {
|
||||||
|
currentLang = lang
|
||||||
|
const isRtl = RTL_LANGS.includes(lang)
|
||||||
|
I18nManager.forceRTL(isRtl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRTL(): boolean {
|
||||||
|
return RTL_LANGS.includes(currentLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLang(): MobileLang {
|
||||||
|
return currentLang
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: string): string {
|
||||||
|
return translations[currentLang]?.[key] ?? translations.fr[key] ?? translations.en[key] ?? key
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
|
try { await apiFetch(ENDPOINTS.logout, { method: 'POST' }) } catch {}
|
||||||
await clearToken()
|
await clearToken()
|
||||||
set({ user: null })
|
set({ user: null })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export const C = {
|
|||||||
concrete: '#8A8A82',
|
concrete: '#8A8A82',
|
||||||
border: '#E8E6E0',
|
border: '#E8E6E0',
|
||||||
white: '#FFFFFF',
|
white: '#FFFFFF',
|
||||||
|
surface: '#F5F4F0',
|
||||||
rose: '#e11d48',
|
rose: '#e11d48',
|
||||||
roseBg: '#fff1f2',
|
roseBg: '#fff1f2',
|
||||||
roseBorder: '#fecdd3',
|
roseBorder: '#fecdd3',
|
||||||
|
emerald: '#10b981',
|
||||||
|
emeraldBg: '#ecfdf5',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
amber: '#f59e0b',
|
||||||
|
purple: '#8b5cf6',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,21 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
Database,
|
Database,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Menu,
|
Menu,
|
||||||
Network,
|
Network,
|
||||||
List,
|
List,
|
||||||
|
Search,
|
||||||
|
ArrowUpDown,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import Link from 'next/link'
|
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(
|
const NetworkGraph = dynamic(
|
||||||
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
||||||
@@ -98,9 +105,28 @@ export default function InsightsPage() {
|
|||||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
|
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
|
||||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
|
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 [lastSyncTime, setLastSyncTime] = useState<string>('')
|
||||||
|
const peek = useNotePeek()
|
||||||
const prefersReducedMotion = useReducedMotion()
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
|
||||||
|
const DASHBOARD_PREVIEW = 8
|
||||||
|
const SUGGESTIONS_PREVIEW = 3
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialData()
|
loadInitialData()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -124,6 +150,43 @@ export default function InsightsPage() {
|
|||||||
return clusters.filter(c => !networkedIds.has(c.id))
|
return clusters.filter(c => !networkedIds.has(c.id))
|
||||||
}, [clusters, bridgeNotes])
|
}, [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(
|
const bridgeList = useMemo(
|
||||||
() =>
|
() =>
|
||||||
bridgeNotes.map(b => ({
|
bridgeNotes.map(b => ({
|
||||||
@@ -133,6 +196,53 @@ export default function InsightsPage() {
|
|||||||
[bridgeNotes, t]
|
[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 ──────────────────────────────────────────────────────
|
// ─── Chargement initial ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
@@ -258,9 +368,60 @@ export default function InsightsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNoteClick = (noteId: string) => {
|
const handleNoteClick = (noteId: string) => {
|
||||||
router.push(`/home?openNote=${noteId}`)
|
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
|
const motionConfig = prefersReducedMotion
|
||||||
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
|
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
|
||||||
: {}
|
: {}
|
||||||
@@ -274,9 +435,9 @@ export default function InsightsPage() {
|
|||||||
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
|
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="lg:hidden p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
className="p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
|
||||||
aria-label={t('sidebar.openNavigation') || 'Open navigation'}
|
aria-label="Toggle sidebar"
|
||||||
>
|
>
|
||||||
<Menu size={22} />
|
<Menu size={22} />
|
||||||
</button>
|
</button>
|
||||||
@@ -432,7 +593,10 @@ export default function InsightsPage() {
|
|||||||
<Network size={12} /> {t('insightsView.viewGraph')}
|
<Network size={12} /> {t('insightsView.viewGraph')}
|
||||||
</button>
|
</button>
|
||||||
<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 ${
|
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'
|
graphMode === 'list'
|
||||||
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
|
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
|
||||||
@@ -464,53 +628,145 @@ export default function InsightsPage() {
|
|||||||
untitledLabel={t('insightsView.unknownNote')}
|
untitledLabel={t('insightsView.unknownNote')}
|
||||||
resetFocusLabel={t('insightsView.resetFocus')}
|
resetFocusLabel={t('insightsView.resetFocus')}
|
||||||
fitViewLabel={t('insightsView.fitGraphView')}
|
fitViewLabel={t('insightsView.fitGraphView')}
|
||||||
|
legendFilterPlaceholder={t('insightsView.legendFilterPlaceholder')}
|
||||||
|
legendShowMoreLabel={t('insightsView.legendShowMore')}
|
||||||
|
legendShowLessLabel={t('insightsView.legendShowLess')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vue liste accessible (a11y fallback) */}
|
{/* Vue liste accessible (accordion + filtre + tri) */}
|
||||||
{graphMode === 'list' && (
|
{graphMode === 'list' && (
|
||||||
<div
|
<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"
|
role="region"
|
||||||
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
|
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
|
||||||
>
|
>
|
||||||
{clusters.map(cluster => {
|
<div className="shrink-0 flex flex-col sm:flex-row gap-2">
|
||||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
|
<div className="relative flex-1 min-w-0">
|
||||||
const clusterBridges = bridgeNotes.filter(b =>
|
<Search
|
||||||
b.clustersConnected?.some(cid => String(cid) === cluster.id)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cluster.id}
|
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">
|
<button
|
||||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cluster.color }} />
|
type="button"
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">
|
onClick={() => {
|
||||||
{cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId })}
|
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>
|
</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')}
|
{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>
|
</span>
|
||||||
</div>
|
)}
|
||||||
<ul className="space-y-1">
|
</span>
|
||||||
{clusterNotes.map(note => (
|
</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}>
|
<li key={note.id}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleNoteClick(note.id)}
|
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" />
|
<ChevronRight size={11} className="text-concrete shrink-0" aria-hidden />
|
||||||
<span className="truncate">{note.title || t('insightsView.unknownNote')}</span>
|
<span className="truncate">
|
||||||
|
{note.title || t('insightsView.unknownNote')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -521,7 +777,33 @@ export default function InsightsPage() {
|
|||||||
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
|
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) */}
|
{/* Avertissement d'obsolescence (stale banner) */}
|
||||||
{isStale && !isCalculating && (
|
{isStale && !isCalculating && (
|
||||||
@@ -614,7 +896,7 @@ export default function InsightsPage() {
|
|||||||
{clusters.length}
|
{clusters.length}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
||||||
{t('insightsView.graphNotesLabel')}
|
{t('insightsView.stats.themesSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,26 +912,48 @@ export default function InsightsPage() {
|
|||||||
{bridgeNotes.length}
|
{bridgeNotes.length}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
||||||
{t('insightsView.bridgeCount')}
|
{t('insightsView.stats.bridgesSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ③ Système de Recalcul */}
|
{/* ③ Système de Recalcul (replié par défaut) */}
|
||||||
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
|
<section className="rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Sliders size={15} className="text-ochre" />
|
<Sliders size={15} className="text-ochre" />
|
||||||
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
||||||
{t('insightsView.recalcSystem.title')}
|
{t('insightsView.recalcSystem.title')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</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">
|
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
|
||||||
<CheckCircle2 size={11} /> {t('insightsView.recalcSystem.statusSynced')}
|
<CheckCircle2 size={11} /> {t('insightsView.recalcSystem.statusSynced')}
|
||||||
</span>
|
</span>
|
||||||
|
{dashboardSectionOpen.recalc ? (
|
||||||
|
<ChevronDown size={14} className="text-concrete" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} className="text-concrete" aria-hidden />
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<span className="text-[9px] text-concrete block">{t('insightsView.recalcSystem.scheduledCron')}</span>
|
<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">
|
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
|
||||||
@@ -673,15 +977,12 @@ export default function InsightsPage() {
|
|||||||
})
|
})
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</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
|
||||||
? `${embeddingStats.indexed} / ${embeddingStats.total}`
|
? `${embeddingStats.indexed} / ${embeddingStats.total}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[8px] text-concrete italic block leading-relaxed">
|
|
||||||
{t('insightsView.tipClusters')}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleReindexEmbeddings()}
|
onClick={() => void handleReindexEmbeddings()}
|
||||||
@@ -695,61 +996,122 @@ export default function InsightsPage() {
|
|||||||
)}
|
)}
|
||||||
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
|
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[8px] text-concrete italic block leading-relaxed">
|
|
||||||
{t('insightsView.tipClustersAction')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ④ Clusters Isolés */}
|
{/* ④ Clusters Isolés */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4 px-1">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
type="button"
|
||||||
<AlertCircle size={15} className="text-rose-400" />
|
onClick={() => setDashboardSectionOpen(s => ({ ...s, isolated: !s.isolated }))}
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
aria-expanded={dashboardSectionOpen.isolated}
|
||||||
{t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}
|
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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-concrete italic">{t('insightsView.tipIsolatedAction')}</span>
|
{dashboardSectionOpen.isolated ? (
|
||||||
</div>
|
<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">
|
<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
|
<motion.div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||||
onClick={() => setSelectedClusterId(c.id)}
|
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"
|
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="flex items-center gap-2.5 min-w-0">
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
<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">
|
<span className="text-xs font-medium text-ink dark:text-dark-ink truncate">
|
||||||
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
|
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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')}
|
{t('insightsView.isolatedClusters.badge')}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ⑤ Notes-Ponts Influentes */}
|
{/* ⑤ Notes-Ponts Influentes */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2 px-1">
|
<button
|
||||||
<Zap size={16} className="text-ochre" />
|
type="button"
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
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')}
|
{t('insightsView.bridgeNotes.title')}
|
||||||
|
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
|
||||||
|
({filteredBridgeList.length})
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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">
|
<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
|
<motion.div
|
||||||
key={bridge.noteId}
|
key={bridge.noteId}
|
||||||
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
|
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
|
||||||
@@ -759,98 +1121,212 @@ export default function InsightsPage() {
|
|||||||
role="button"
|
role="button"
|
||||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2 gap-4">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
|
<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}
|
{bridge.title}
|
||||||
</h4>
|
</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">
|
<span
|
||||||
{t('insightsView.bridgeNotes.score', {
|
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),
|
score: (bridge.bridgeScore * 100).toFixed(0),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 => {
|
{nameB ? (
|
||||||
const cluster = clusters.find(c => c.id === String(cid))
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
return (
|
<button
|
||||||
<div
|
type="button"
|
||||||
key={cid}
|
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
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
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
|
style={{ backgroundColor: themeA?.color || '#cbd5e1' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
|
||||||
{cluster?.name || t('insightsView.clusterFallback', { index: cid })}
|
{nameA}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-concrete italic">
|
||||||
|
{t('insightsView.bridgeNotes.needsResync')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
{!dashboardFilter.trim() && filteredBridgeList.length > DASHBOARD_PREVIEW && (
|
||||||
</motion.div>
|
<button
|
||||||
))}
|
type="button"
|
||||||
{bridgeList.length === 0 && !isCalculating && (
|
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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ⑥ Opportunités de Connexion */}
|
{/* ⑥ Opportunités de Connexion (link prediction — top near-miss pairs) */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2 px-1">
|
<button
|
||||||
<Lightbulb size={16} className="text-indigo-500" />
|
type="button"
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
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')}
|
{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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-concrete italic px-1 -mt-2 leading-relaxed">{t('insightsView.tipSuggestions')}</p>
|
{dashboardSectionOpen.suggestions ? (
|
||||||
<div className="space-y-4">
|
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
|
||||||
{suggestions.map(s => (
|
) : (
|
||||||
|
<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
|
<div
|
||||||
key={`${s.clusterAId}-${s.clusterBId}`}
|
key={key}
|
||||||
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"
|
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 items-start justify-between gap-2 mb-2">
|
||||||
<div className="flex -space-x-2 shrink-0">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<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">
|
<span
|
||||||
A
|
className="w-2 h-2 rounded-full shrink-0 bg-indigo-500"
|
||||||
</div>
|
aria-hidden
|
||||||
<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
|
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
|
||||||
</div>
|
{s.clusterAName}
|
||||||
</div>
|
</span>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
|
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
|
||||||
{t('insightsView.suggestions.bridging', {
|
↔
|
||||||
clusterA: s.clusterAName,
|
</span>
|
||||||
clusterB: s.clusterBName,
|
<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>
|
</span>
|
||||||
</div>
|
</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}
|
{s.suggestedTitle}
|
||||||
</h4>
|
</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}
|
{s.suggestedContent}
|
||||||
</p>
|
</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">
|
<button
|
||||||
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
|
type="button"
|
||||||
<span>{s.justification}</span>
|
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>
|
||||||
</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 && (
|
{isCalculating && (
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-3">
|
||||||
{[1, 2].map(i => (
|
{[1, 2].map(i => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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>
|
</div>
|
||||||
@@ -861,6 +1337,7 @@ export default function InsightsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -868,6 +1345,16 @@ export default function InsightsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Peek panel (factorisé) ── */}
|
||||||
|
<NotePeekPanel
|
||||||
|
note={peek.note}
|
||||||
|
blockId={peek.blockId}
|
||||||
|
loading={peek.loading}
|
||||||
|
mode="overlay"
|
||||||
|
onClose={peek.close}
|
||||||
|
onOpenFully={(n) => { router.push(`/home?openNote=${n.id}`); peek.close() }}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ export default async function MainLayout({
|
|||||||
>
|
>
|
||||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||||
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
||||||
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
|
||||||
<Sidebar user={session?.user} />
|
<Sidebar user={session?.user} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark: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>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
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 { toast } from 'sonner'
|
||||||
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function IntegrationsPage() {
|
export default function IntegrationsPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
// ── Readwise ───────────────────────────────────────────────────────────
|
// ── Readwise ───────────────────────────────────────────────────────────
|
||||||
const [rwToken, setRwToken] = useState('')
|
const [rwToken, setRwToken] = useState('')
|
||||||
const [rwConnected, setRwConnected] = useState(false)
|
const [rwConnected, setRwConnected] = useState(false)
|
||||||
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
|
|||||||
const [calEvents, setCalEvents] = useState<any[]>([])
|
const [calEvents, setCalEvents] = useState<any[]>([])
|
||||||
const [calFetching, setCalFetching] = useState(false)
|
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)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/integrations/readwise').then((r) => r.json()),
|
fetch('/api/integrations/readwise').then((r) => r.json()),
|
||||||
fetch('/api/integrations/calendar').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)
|
setRwConnected(rw.connected)
|
||||||
setCalConnected(cal.connected)
|
setCalConnected(cal.connected)
|
||||||
|
setGmailConnected(gmail.connected)
|
||||||
|
setGmailRecent(gmail.recentCaptures ?? 0)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setCalLoading(false)
|
setCalLoading(false)
|
||||||
|
setGmailLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle redirect params
|
// Handle redirect params
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
if (params.get('connected') === 'calendar') {
|
if (params.get('connected') === 'calendar') {
|
||||||
setCalConnected(true)
|
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')
|
window.history.replaceState({}, '', '/settings/integrations')
|
||||||
}
|
}
|
||||||
if (params.get('error')) {
|
if (params.get('error')) {
|
||||||
toast.error(`Erreur: ${params.get('error')}`)
|
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
|
||||||
window.history.replaceState({}, '', '/settings/integrations')
|
window.history.replaceState({}, '', '/settings/integrations')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
// ── Readwise handlers ──────────────────────────────────────────────────
|
// ── Readwise handlers ──────────────────────────────────────────────────
|
||||||
const handleRwConnect = async () => {
|
const handleRwConnect = async () => {
|
||||||
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
|
|||||||
body: JSON.stringify({ token: rwToken.trim() }),
|
body: JSON.stringify({ token: rwToken.trim() }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
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)
|
setRwConnected(true)
|
||||||
setRwToken('')
|
setRwToken('')
|
||||||
setRwLastSync({ created: data.created, updated: data.updated })
|
setRwLastSync({ created: data.created, updated: data.updated })
|
||||||
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
|
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
|
||||||
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
|
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRwSync = async () => {
|
const handleRwSync = async () => {
|
||||||
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
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 })
|
setRwLastSync({ created: data.created, updated: data.updated })
|
||||||
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
|
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
|
||||||
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
|
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRwDisconnect = async () => {
|
const handleRwDisconnect = async () => {
|
||||||
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
||||||
setRwConnected(false)
|
setRwConnected(false)
|
||||||
toast.success('Readwise déconnecté')
|
toast.success(t('integrations.readwiseDisconnected'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar handlers ──────────────────────────────────────────────────
|
// ── Calendar handlers ──────────────────────────────────────────────────
|
||||||
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/integrations/calendar?events=1')
|
const res = await fetch('/api/integrations/calendar?events=1')
|
||||||
const data = await res.json()
|
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 ?? [])
|
setCalEvents(data.events ?? [])
|
||||||
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
|
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
|
||||||
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
|
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateMeetingNote = async (event: any) => {
|
const handleCreateMeetingNote = async (event: any) => {
|
||||||
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(`Note de réunion créée : ${event.summary}`, {
|
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
|
||||||
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
||||||
})
|
})
|
||||||
} else {
|
} 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' })
|
await fetch('/api/integrations/calendar', { method: 'DELETE' })
|
||||||
setCalConnected(false)
|
setCalConnected(false)
|
||||||
setCalEvents([])
|
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 }) =>
|
const StatusBadge = ({ connected }: { connected: boolean }) =>
|
||||||
connected ? (
|
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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
|
<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">Connectez des services externes à Memento.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
||||||
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
||||||
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
|
|||||||
{!calConnected ? (
|
{!calConnected ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SettingsHelpBox
|
<SettingsHelpBox
|
||||||
title="Comment fonctionne Google Calendar ?"
|
title={t('integrations.calendarInfo')}
|
||||||
steps={[
|
steps={[
|
||||||
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
|
{ text: t('integrations.calendarHelpStep1') },
|
||||||
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
|
{ text: t('integrations.calendarHelpStep2') },
|
||||||
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
|
{ text: t('integrations.calendarHelpStep3') },
|
||||||
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
|
{ text: t('integrations.calendarHelpStep4') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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} />
|
<CalendarDays size={14} />
|
||||||
Connecter Google Calendar
|
{t('integrations.connectCalendar')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
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} />}
|
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
|
||||||
Événements aujourd'hui
|
{t('integrations.todayEvents')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCalDisconnect}
|
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"
|
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} />
|
<Trash2 size={14} />
|
||||||
Déconnecter
|
{t('integrations.disconnect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
|
|||||||
onClick={() => handleCreateMeetingNote(ev)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsHelpBox
|
<SettingsHelpBox
|
||||||
title="Comment fonctionne Readwise ?"
|
title={t('integrations.readwiseInfo')}
|
||||||
steps={[
|
steps={[
|
||||||
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
|
{ text: t('integrations.readwiseHelpStep1'), 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: t('integrations.readwiseHelpStep2') },
|
||||||
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
|
{ text: t('integrations.readwiseHelpStep3') },
|
||||||
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
|
{ text: t('integrations.readwiseHelpStep4') },
|
||||||
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
|
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={rwToken}
|
value={rwToken}
|
||||||
onChange={(e) => setRwToken(e.target.value)}
|
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"
|
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()}
|
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"
|
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}
|
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
Connecter
|
{t('integrations.connect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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} />}
|
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||||
Synchroniser maintenant
|
{t('integrations.syncNow')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRwDisconnect}
|
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"
|
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} />
|
<Trash2 size={14} />
|
||||||
Déconnecter
|
{t('integrations.disconnect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rwLastSync && (
|
{rwLastSync && (
|
||||||
<p className="text-[11px] text-concrete">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder */}
|
{/* Placeholder */}
|
||||||
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
|
<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>
|
||||||
</div>
|
</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 { auth } from '@/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
|
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
|
||||||
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
import { listMcpKeys, getMcpServerStatus, getMcpAccessStatus } from '@/app/actions/mcp-keys'
|
||||||
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
import { McpSettingsHeader } from './mcp-settings-header'
|
||||||
|
|
||||||
export default async function McpSettingsPage() {
|
export default async function McpSettingsPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect('/api/auth/signin')
|
if (!session?.user) redirect('/api/auth/signin')
|
||||||
|
|
||||||
const [keys, serverStatus] = await Promise.all([
|
const [keys, serverStatus, access] = await Promise.all([
|
||||||
listMcpKeys(),
|
listMcpKeys(),
|
||||||
getMcpServerStatus(),
|
getMcpServerStatus(),
|
||||||
|
getMcpAccessStatus(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SettingsHelpBox
|
<McpSettingsHeader />
|
||||||
title="Qu'est-ce que MCP (Model Context Protocol) ?"
|
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} mcpAllowed={access.allowed} tier={access.tier} />
|
||||||
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} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Note, Notebook } from '@/lib/types'
|
import { Note, Notebook } from '@/lib/types'
|
||||||
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
|
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
|
||||||
import { restoreNotebook, permanentDeleteNotebook } from '@/context/notebooks-context'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
|
|||||||
import {
|
import {
|
||||||
getAllEntitlementsForAdmin,
|
getAllEntitlementsForAdmin,
|
||||||
invalidateEntitlementCache,
|
invalidateEntitlementCache,
|
||||||
|
ENTITLEMENT_UNAVAILABLE,
|
||||||
type SubscriptionTier as TierType,
|
type SubscriptionTier as TierType,
|
||||||
} from '@/lib/plan-entitlements'
|
} from '@/lib/plan-entitlements'
|
||||||
import { logAuditEventAsync } from '@/lib/audit-log'
|
import { logAuditEventAsync } from '@/lib/audit-log'
|
||||||
@@ -73,9 +74,20 @@ export async function updatePlanEntitlement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'unavailable') {
|
if (mode === 'unavailable') {
|
||||||
await prisma.planEntitlement.deleteMany({
|
await prisma.planEntitlement.upsert({
|
||||||
where: { tier: tier as SubscriptionTier, feature },
|
where: {
|
||||||
})
|
tier_feature: {
|
||||||
|
tier: tier as SubscriptionTier,
|
||||||
|
feature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { limitValue: ENTITLEMENT_UNAVAILABLE },
|
||||||
|
create: {
|
||||||
|
tier: tier as SubscriptionTier,
|
||||||
|
feature,
|
||||||
|
limitValue: ENTITLEMENT_UNAVAILABLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await prisma.planEntitlement.upsert({
|
await prisma.planEntitlement.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -158,7 +170,7 @@ export async function updateBillingConfig(data: Record<string, string>) {
|
|||||||
|
|
||||||
async function getUsageOverviewInternal() {
|
async function getUsageOverviewInternal() {
|
||||||
const period = getCurrentPeriodKey()
|
const period = getCurrentPeriodKey()
|
||||||
const periodStart = new Date(`${period}-01`)
|
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
|
||||||
|
|
||||||
const aggregated = await prisma.usageLog.groupBy({
|
const aggregated = await prisma.usageLog.groupBy({
|
||||||
by: ['feature'],
|
by: ['feature'],
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
|||||||
revalidatePath('/settings/ai', 'page')
|
revalidatePath('/settings/ai', 'page')
|
||||||
revalidatePath('/settings/appearance', 'page')
|
revalidatePath('/settings/appearance', 'page')
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
revalidateTag('ai-settings')
|
revalidateTag('ai-settings', 'default')
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { auth } from '@/auth'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { createHash, randomBytes } from 'crypto'
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import { assertMcpAccess, isMcpTierAllowed } from '@/lib/mcp-access'
|
||||||
|
import { getEffectiveTier } from '@/lib/entitlements'
|
||||||
|
|
||||||
const KEY_PREFIX = 'mcp_key_'
|
const KEY_PREFIX = 'mcp_key_'
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ export async function generateMcpKey(name: string): Promise<{ rawKey: string; in
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||||
|
|
||||||
|
await assertMcpAccess(session.user.id)
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
@@ -155,6 +159,13 @@ export type McpServerStatus = {
|
|||||||
url: string | null
|
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.
|
* Get MCP server status — mode and URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -472,7 +472,6 @@ export async function createNote(data: {
|
|||||||
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
||||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||||
|
|
||||||
// console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
|
||||||
|
|
||||||
if (autoLabelingEnabled) {
|
if (autoLabelingEnabled) {
|
||||||
// Detect user's language from their existing notes for localized prompts
|
// Detect user's language from their existing notes for localized prompts
|
||||||
@@ -497,7 +496,6 @@ export async function createNote(data: {
|
|||||||
userLang
|
userLang
|
||||||
)
|
)
|
||||||
|
|
||||||
// console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
|
||||||
|
|
||||||
const appliedLabels = suggestions
|
const appliedLabels = suggestions
|
||||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||||
@@ -531,7 +529,6 @@ export async function createNote(data: {
|
|||||||
console.error('[BG] Auto-labeling failed:', error)
|
console.error('[BG] Auto-labeling failed:', error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
|
||||||
}
|
}
|
||||||
})().catch(e => console.error('[BG] Uncaught background error:', e))
|
})().catch(e => console.error('[BG] Uncaught background error:', e))
|
||||||
|
|
||||||
@@ -632,14 +629,12 @@ export async function updateNote(id: string, data: {
|
|||||||
updateData.contentUpdatedAt = new Date()
|
updateData.contentUpdatedAt = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
|
|
||||||
let note
|
let note
|
||||||
try {
|
try {
|
||||||
note = await prisma.note.update({
|
note = await prisma.note.update({
|
||||||
where: { id, userId: session.user.id },
|
where: { id, userId: session.user.id },
|
||||||
data: updateData
|
data: updateData
|
||||||
})
|
})
|
||||||
// console.log('[updateNote] Succeeded, note id:', note?.id)
|
|
||||||
} catch (dbError: any) {
|
} catch (dbError: any) {
|
||||||
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
||||||
throw dbError
|
throw dbError
|
||||||
@@ -693,7 +688,6 @@ export async function updateNote(id: string, data: {
|
|||||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||||
const isStructuralChange = structuralFields.some(field => field in data)
|
const isStructuralChange = structuralFields.some(field => field in data)
|
||||||
|
|
||||||
// console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
|
||||||
|
|
||||||
if (!options?.skipRevalidation) {
|
if (!options?.skipRevalidation) {
|
||||||
try { revalidatePath(`/note/${id}`) } catch {}
|
try { revalidatePath(`/note/${id}`) } catch {}
|
||||||
@@ -952,17 +946,25 @@ export async function syncAllEmbeddings() {
|
|||||||
userId,
|
userId,
|
||||||
trashedAt: null,
|
trashedAt: null,
|
||||||
noteEmbedding: { is: null }
|
noteEmbedding: { is: null }
|
||||||
}
|
},
|
||||||
|
take: 200,
|
||||||
})
|
})
|
||||||
for (const note of notesToSync) {
|
|
||||||
if (!note.content) continue;
|
const BATCH_SIZE = 5;
|
||||||
|
for (let i = 0; i < notesToSync.length; i += BATCH_SIZE) {
|
||||||
|
const batch = notesToSync.slice(i, i + BATCH_SIZE);
|
||||||
|
await Promise.allSettled(batch.map(async (note) => {
|
||||||
|
if (!note.content) return;
|
||||||
try {
|
try {
|
||||||
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
await upsertNoteEmbedding(note.id, embedding)
|
await upsertNoteEmbedding(note.id, embedding)
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
}
|
}
|
||||||
} catch (e) { }
|
} catch (e) {
|
||||||
|
console.error(`[syncEmbeddings] Failed for note ${note.id}:`, e)
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return { success: true, count: updatedCount }
|
return { success: true, count: updatedCount }
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1080,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
|
// Dismiss a note from Recent section
|
||||||
export async function dismissFromRecent(id: string) {
|
export async function dismissFromRecent(id: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { withAiQuota } from '@/lib/ai-quota'
|
||||||
|
import { QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export interface OrganizationNote {
|
export interface OrganizationNote {
|
||||||
id: string
|
id: string
|
||||||
@@ -134,8 +136,16 @@ Format de réponse JSON attendu:
|
|||||||
|
|
||||||
let rawResponse: string
|
let rawResponse: string
|
||||||
try {
|
try {
|
||||||
rawResponse = await provider.generateText(prompt)
|
rawResponse = await withAiQuota(
|
||||||
|
session.user.id,
|
||||||
|
'reformulate',
|
||||||
|
() => provider.generateText(prompt),
|
||||||
|
{ lane: 'chat' },
|
||||||
|
)
|
||||||
} catch (aiErr) {
|
} catch (aiErr) {
|
||||||
|
if (aiErr instanceof QuotaExceededError) {
|
||||||
|
return { success: false, error: 'Quota IA épuisé pour cette fonctionnalité.' }
|
||||||
|
}
|
||||||
console.error('[organize-notebook] AI generateText failed:', aiErr)
|
console.error('[organize-notebook] AI generateText failed:', aiErr)
|
||||||
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
|
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
|
||||||
}
|
}
|
||||||
@@ -205,11 +215,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
|||||||
})
|
})
|
||||||
if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' }
|
if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' }
|
||||||
|
|
||||||
console.log('[organize-notebook] Executing plan:', JSON.stringify({
|
|
||||||
notebookId: plan.notebookId,
|
|
||||||
groups: plan.groups.map(g => ({ name: g.name, isNew: g.isNew, existingId: g.existingId, noteCount: g.notes.length }))
|
|
||||||
}))
|
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
let moved = 0
|
let moved = 0
|
||||||
|
|
||||||
@@ -254,7 +259,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(`[organize-notebook] Created sub-notebook "${newSub.name}" (id: ${newSub.id}) under ${plan.notebookId}`)
|
|
||||||
targetNotebookId = newSub.id
|
targetNotebookId = newSub.id
|
||||||
created++
|
created++
|
||||||
}
|
}
|
||||||
@@ -269,7 +273,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
|||||||
},
|
},
|
||||||
data: { notebookId: targetNotebookId },
|
data: { notebookId: targetNotebookId },
|
||||||
})
|
})
|
||||||
console.log(`[organize-notebook] Moved ${result.count} notes to "${group.name}"`)
|
|
||||||
moved += result.count
|
moved += result.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
||||||
@@ -58,11 +60,16 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get label suggestions
|
const suggestions = await withAiQuota(
|
||||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
|
||||||
notebookId,
|
|
||||||
session.user.id,
|
session.user.id,
|
||||||
language
|
'auto_tag',
|
||||||
|
() =>
|
||||||
|
autoLabelCreationService.suggestLabels(
|
||||||
|
notebookId,
|
||||||
|
session.user!.id,
|
||||||
|
language,
|
||||||
|
),
|
||||||
|
{ lane: 'tags' },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!suggestions) {
|
if (!suggestions) {
|
||||||
@@ -78,8 +85,10 @@ export async function POST(request: NextRequest) {
|
|||||||
data: suggestions,
|
data: suggestions,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
console.error('[/api/ai/auto-labels] POST failed:', error)
|
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 { auth } from '@/auth'
|
||||||
import { batchOrganizationService } from '@/lib/ai/services'
|
import { batchOrganizationService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
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
|
* 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
|
// Create organization plan
|
||||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -11,7 +12,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// GDPR AI Consent check
|
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
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 })
|
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,
|
imageUrls,
|
||||||
mode === 'title' ? 'title' : 'description',
|
isTitleMode ? 'title' : 'description',
|
||||||
language || 'fr'
|
language || 'fr'
|
||||||
|
),
|
||||||
|
{ lane },
|
||||||
)
|
)
|
||||||
|
|
||||||
// For title mode, return suggestions in same format as /api/ai/title-suggestions
|
if (isTitleMode && result.suggestions) {
|
||||||
if (mode === 'title' && result.suggestions) {
|
|
||||||
return NextResponse.json({ suggestions: result.suggestions })
|
return NextResponse.json({ suggestions: result.suggestions })
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result)
|
return NextResponse.json(result)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
console.error('[describe-image] Error:', error)
|
console.error('[describe-image] Error:', error)
|
||||||
return NextResponse.json(
|
const message = error instanceof Error ? error.message : 'Failed to describe image'
|
||||||
{ error: error.message || 'Failed to describe image' },
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getChatProvider } from '@/lib/ai/factory'
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ai/echo/fusion
|
* POST /api/ai/echo/fusion
|
||||||
@@ -34,7 +35,6 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the notes
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: noteIds },
|
id: { in: noteIds },
|
||||||
@@ -55,11 +55,9 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get AI provider
|
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getChatProvider(config)
|
const provider = getChatProvider(config)
|
||||||
|
|
||||||
// Build fusion prompt
|
|
||||||
const notesDescriptions = notes.map((note, index) => {
|
const notesDescriptions = notes.map((note, index) => {
|
||||||
return `Note ${index + 1}: "${note.title || 'Untitled'}"
|
return `Note ${index + 1}: "${note.title || 'Untitled'}"
|
||||||
${note.content}`
|
${note.content}`
|
||||||
@@ -90,21 +88,21 @@ Output format:
|
|||||||
|
|
||||||
Begin:`
|
Begin:`
|
||||||
|
|
||||||
try {
|
const fusedContent = await withAiQuota(
|
||||||
const fusedContent = await provider.generateText(fusionPrompt)
|
session.user.id,
|
||||||
|
'reformulate',
|
||||||
|
() => provider.generateText(fusionPrompt),
|
||||||
|
{ lane: 'chat' },
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
fusedNote: fusedContent,
|
fusedNote: fusedContent,
|
||||||
notesCount: notes.length
|
notesCount: notes.length
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
{ error: 'Failed to generate fusion' },
|
if (quotaResp) return quotaResp
|
||||||
{ status: 500 }
|
console.error('[/api/ai/echo/fusion] Error:', error)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to process fusion request' },
|
{ error: 'Failed to process fusion request' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ai/echo
|
* GET /api/ai/echo
|
||||||
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get next insight (respects frequency limits)
|
// Get next insight (respects frequency limits)
|
||||||
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
||||||
|
|
||||||
if (!insight) {
|
if (!insight) {
|
||||||
@@ -41,6 +43,9 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ insight })
|
return NextResponse.json({ insight })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof QuotaExceededError) {
|
||||||
|
return NextResponse.json(error.toJSON(), { status: 429 })
|
||||||
|
}
|
||||||
console.error('[/api/ai/echo] GET error:', error)
|
console.error('[/api/ai/echo] GET error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch Memory Echo insight' },
|
{ error: 'Failed to fetch Memory Echo insight' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getTagsProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
|
|||||||
let prompt: string
|
let prompt: string
|
||||||
|
|
||||||
if (mode === 'complete') {
|
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.
|
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.
|
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
|
- Format the new content consistently with the existing note style and the requested FORMAT RULE
|
||||||
- Respond ONLY with the enriched note content, no explanations`
|
- Respond ONLY with the enriched note content, no explanations`
|
||||||
} else {
|
} 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.
|
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.
|
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`
|
- 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() })
|
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)
|
console.error('[enrich-from-resource] Error:', error)
|
||||||
return NextResponse.json(
|
const message = error instanceof Error ? error.message : 'Failed to enrich content'
|
||||||
{ error: error.message || 'Failed to enrich content' },
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSummaryService } from '@/lib/ai/services'
|
import { notebookSummaryService } from '@/lib/ai/services'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
||||||
@@ -50,6 +52,8 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||||
|
|
||||||
// Generate summary
|
// Generate summary
|
||||||
const summary = await notebookSummaryService.generateSummary(
|
const summary = await notebookSummaryService.generateSummary(
|
||||||
notebookId,
|
notebookId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
|
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'
|
import { syncNoteLabels } from '@/app/actions/notes'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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 })
|
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({
|
const notes = await prisma.note.findMany({
|
||||||
where: { notebookId, trashedAt: null, userId: session.user.id },
|
where: { notebookId, trashedAt: null, userId: session.user.id },
|
||||||
select: { id: true, title: true, content: true },
|
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),
|
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)
|
return NextResponse.json(result)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
console.error('[Notebook Organizer] Error:', error)
|
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,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
|
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -14,11 +15,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
|
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)
|
return NextResponse.json(overview)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
console.error('[Search Overview] Error:', error)
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 })
|
return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLang = await prisma.user.findUnique({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
select: { language: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const notesForService = notes.map(n => ({ id: n.id, title: n.title ?? '' }))
|
const notesForService = notes.map(n => ({ id: n.id, title: n.title ?? '' }))
|
||||||
const plan = await studyPlannerService.generate(notesForService, examDate, userLang?.language || 'fr')
|
const plan = await studyPlannerService.generate(notesForService, examDate, 'fr')
|
||||||
|
|
||||||
// Set the first occurrence reminder for each note (subsequent occurrences ignored)
|
// Set the first occurrence reminder for each note (subsequent occurrences ignored)
|
||||||
const seenNoteIds = new Set<string>()
|
const seenNoteIds = new Set<string>()
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ interface SuggestChartsResponse {
|
|||||||
analyzedText: string
|
analyzedText: string
|
||||||
detectedData: string
|
detectedData: string
|
||||||
hasData: boolean
|
hasData: boolean
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
console.log('[suggest-charts] ===== REQUEST START =====')
|
|
||||||
|
|
||||||
// 1. Auth check
|
// 1. Auth check
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@@ -47,16 +47,13 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
console.log('[suggest-charts] userId:', userId)
|
|
||||||
|
|
||||||
// 1.5 Quota check
|
// 1.5 Quota check
|
||||||
try {
|
try {
|
||||||
const sysConfigEarly = await getSystemConfig()
|
const sysConfigEarly = await getSystemConfig()
|
||||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||||
console.log('[suggest-charts] BYOK:', willUseByok)
|
|
||||||
if (!willUseByok) {
|
if (!willUseByok) {
|
||||||
await reserveUsageOrThrow(userId, 'suggest_charts')
|
await reserveUsageOrThrow(userId, 'suggest_charts')
|
||||||
console.log('[suggest-charts] Quota OK')
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[suggest-charts] QUOTA ERROR:', err)
|
console.error('[suggest-charts] QUOTA ERROR:', err)
|
||||||
@@ -84,7 +81,6 @@ export async function POST(req: Request) {
|
|||||||
} satisfies SuggestChartsResponse, { status: 400 })
|
} satisfies SuggestChartsResponse, { status: 400 })
|
||||||
}
|
}
|
||||||
const { content, selection, noteId } = body
|
const { content, selection, noteId } = body
|
||||||
console.log('[suggest-charts] contentLen:', content?.length, 'selectionLen:', selection?.length)
|
|
||||||
|
|
||||||
if (!content || content.trim().length === 0) {
|
if (!content || content.trim().length === 0) {
|
||||||
console.error('[suggest-charts] EMPTY CONTENT')
|
console.error('[suggest-charts] EMPTY CONTENT')
|
||||||
@@ -98,7 +94,6 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textToAnalyze = selection && selection.trim() ? selection.trim() : content.trim()
|
const textToAnalyze = selection && selection.trim() ? selection.trim() : content.trim()
|
||||||
console.log('[suggest-charts] analyzeLen:', textToAnalyze.length, 'preview:', textToAnalyze.substring(0, 100))
|
|
||||||
|
|
||||||
// 3. Build AI context
|
// 3. Build AI context
|
||||||
let provider, model
|
let provider, model
|
||||||
@@ -106,7 +101,6 @@ export async function POST(req: Request) {
|
|||||||
const sysConfig = await getSystemConfig()
|
const sysConfig = await getSystemConfig()
|
||||||
provider = getChatProvider(sysConfig)
|
provider = getChatProvider(sysConfig)
|
||||||
model = provider.getModel()
|
model = provider.getModel()
|
||||||
console.log('[suggest-charts] AI model:', model)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[suggest-charts] AI ROUTE ERROR:', e)
|
console.error('[suggest-charts] AI ROUTE ERROR:', e)
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -154,7 +148,6 @@ Response format (COPY this structure):
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 5. Parse AI response - be very lenient
|
// 5. Parse AI response - be very lenient
|
||||||
console.log('[suggest-charts] AI response:', text.substring(0, 500))
|
|
||||||
|
|
||||||
let parsed: SuggestChartsResponse
|
let parsed: SuggestChartsResponse
|
||||||
try {
|
try {
|
||||||
@@ -227,7 +220,7 @@ Response format (COPY this structure):
|
|||||||
if (baseSuggestion) {
|
if (baseSuggestion) {
|
||||||
const types = ['bar', 'line', 'pie'].filter(t => t !== baseSuggestion.type)
|
const types = ['bar', 'line', 'pie'].filter(t => t !== baseSuggestion.type)
|
||||||
while (parsed.suggestions.length < 3 && types.length > 0) {
|
while (parsed.suggestions.length < 3 && types.length > 0) {
|
||||||
parsed.suggestions.push({ ...baseSuggestion, type: types.shift()! })
|
parsed.suggestions.push({ ...baseSuggestion, type: types.shift() as any })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +22,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
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
|
const wordCount = noteContent.trim().split(/\s+/).length
|
||||||
if (wordCount < 20) {
|
if (wordCount < 20) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -31,18 +31,25 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get suggestion from AI service
|
const suggestedNotebook = await withAiQuota(
|
||||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
|
||||||
noteContent,
|
|
||||||
session.user.id,
|
session.user.id,
|
||||||
|
'auto_tag',
|
||||||
|
() => notebookSuggestionService.suggestNotebook(
|
||||||
|
noteContent,
|
||||||
|
session.user!.id,
|
||||||
language
|
language
|
||||||
|
),
|
||||||
|
{ lane: 'tags' },
|
||||||
)
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
suggestion: suggestedNotebook,
|
suggestion: suggestedNotebook,
|
||||||
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
|
confidence: suggestedNotebook ? 0.8 : 0
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
|
console.error('[/api/ai/suggest-notebook] Error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to generate suggestion' },
|
{ error: 'Failed to generate suggestion' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
||||||
@@ -56,7 +57,6 @@ export async function POST(req: NextRequest) {
|
|||||||
// If notebookId is provided, use contextual suggestions (IA2)
|
// If notebookId is provided, use contextual suggestions (IA2)
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
try {
|
try {
|
||||||
console.log('[/api/ai/tags] contextual request — notebookId:', notebookId, 'content length:', content.length)
|
|
||||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||||
content,
|
content,
|
||||||
notebookId,
|
notebookId,
|
||||||
@@ -64,7 +64,6 @@ export async function POST(req: NextRequest) {
|
|||||||
language
|
language
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[/api/ai/tags] suggestions:', suggestions.length, suggestions.map(s => `${s.label}(${s.confidence})`))
|
|
||||||
|
|
||||||
const convertedTags = suggestions.map(s => ({
|
const convertedTags = suggestions.map(s => ({
|
||||||
tag: s.label,
|
tag: s.label,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 { getSystemConfig } from '@/lib/config'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
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 { z } from 'zod'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
|
||||||
@@ -60,10 +62,8 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
|
|
||||||
if (!willUseByok) {
|
|
||||||
try {
|
try {
|
||||||
await reserveUsageOrThrow(session.user.id, 'auto_title')
|
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||||
@@ -73,7 +73,6 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
|
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)
|
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -18,12 +19,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { text } = await request.json()
|
const { text } = await request.json()
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!text || typeof text !== 'string') {
|
if (!text || typeof text !== 'string') {
|
||||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate word count
|
|
||||||
const wordCount = text.split(/\s+/).length
|
const wordCount = text.split(/\s+/).length
|
||||||
if (wordCount < 10) {
|
if (wordCount < 10) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getChatProvider(config)
|
const provider = getChatProvider(config)
|
||||||
|
|
||||||
// Detect language from text
|
|
||||||
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
||||||
const responseLanguage = hasFrench ? 'French' : 'English'
|
const responseLanguage = hasFrench ? 'French' : 'English'
|
||||||
|
|
||||||
// Build prompt to transform text to Markdown
|
|
||||||
const prompt = hasFrench
|
const prompt = hasFrench
|
||||||
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
|
? `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.`
|
Respond ONLY with the transformed Markdown text, no explanations.`
|
||||||
|
|
||||||
|
const transformedText = await withAiQuota(
|
||||||
const transformedText = await provider.generateText(prompt)
|
session.user.id,
|
||||||
|
'reformulate',
|
||||||
|
() => provider.generateText(prompt),
|
||||||
|
{ lane: 'chat' },
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
originalText: text,
|
originalText: text,
|
||||||
transformedText: transformedText,
|
transformedText,
|
||||||
language: responseLanguage
|
language: responseLanguage
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
return NextResponse.json(
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
{ error: error.message || 'Failed to transform text to Markdown' },
|
if (quotaResp) return quotaResp
|
||||||
{ status: 500 }
|
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 { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getTagsProvider(config)
|
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 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)
|
const translatedText = await provider.generateText(prompt)
|
||||||
|
|
||||||
return NextResponse.json({ translatedText: translatedText.trim() })
|
return NextResponse.json({ translatedText: translatedText.trim() })
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(tagPromises)
|
await prisma.$transaction(async () => {
|
||||||
|
await Promise.all(tagPromises)
|
||||||
|
})
|
||||||
|
|
||||||
await logActivity(sessionId, 'idea_converted', session.user.id, { ideaTitle: idea.title, ideaId: idea.id, noteId: note.id })
|
await logActivity(sessionId, 'idea_converted', session.user.id, { ideaTitle: idea.title, ideaId: idea.id, noteId: note.id })
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updatePromises.length > 0) {
|
if (updatePromises.length > 0) {
|
||||||
await prisma.$transaction(updatePromises)
|
await prisma.$transaction(async () => {
|
||||||
|
await Promise.all(updatePromises)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fruitful = Array.from(noteImpactMap.values()).filter(n => n.acceptedCount > 0).length
|
const fruitful = Array.from(noteImpactMap.values()).filter(n => n.acceptedCount > 0).length
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import prisma from '@/lib/prisma'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
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 {
|
function localeToLanguageName(locale?: string): string {
|
||||||
const map: Record<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 })
|
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
||||||
|
brainstormSession.userId,
|
||||||
|
session.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
const ideasText = ideas
|
const ideasText = ideas
|
||||||
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
|
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
|
||||||
.join('\n')
|
.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.`
|
Be direct and action-oriented. No bullet points, just flowing prose.`
|
||||||
|
|
||||||
const config = await getSystemConfig()
|
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',
|
'tags',
|
||||||
config,
|
config,
|
||||||
session.user.id,
|
billingOwnerId,
|
||||||
(provider) => provider.generateText(prompt),
|
(provider) => provider.generateText(prompt),
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ lane: 'tags' },
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
|
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const quotaResp = handleQuotaHttpError(error)
|
||||||
|
if (quotaResp) return quotaResp
|
||||||
console.error('Error summarizing brainstorm:', error)
|
console.error('Error summarizing brainstorm:', error)
|
||||||
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
|
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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,6 @@ async function buildPptx(spec: any): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'bullets': {
|
case 'bullets': {
|
||||||
console.log('[PPTX] Rendering bullets slide:', slide.title, 'items:', slide.items?.length)
|
|
||||||
s.background = { color: p.bg }
|
s.background = { color: p.bg }
|
||||||
s.addShape(pptx.ShapeType.rect, {
|
s.addShape(pptx.ShapeType.rect, {
|
||||||
x: 0, y: 0, w: 0.06, h: H,
|
x: 0, y: 0, w: 0.06, h: H,
|
||||||
@@ -90,7 +89,6 @@ async function buildPptx(spec: any): Promise<Buffer> {
|
|||||||
fill: { color: p.accent }, line: { type: 'none' },
|
fill: { color: p.accent }, line: { type: 'none' },
|
||||||
})
|
})
|
||||||
const items = slide.items ?? []
|
const items = slide.items ?? []
|
||||||
console.log('[PPTX] Bullet items:', items.length, items[0])
|
|
||||||
// Use simpler bullet format - each item as separate text call with bullet option
|
// Use simpler bullet format - each item as separate text call with bullet option
|
||||||
items.forEach((item: string, i: number) => {
|
items.forEach((item: string, i: number) => {
|
||||||
s.addText(item, {
|
s.addText(item, {
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import { prisma } from '@/lib/prisma'
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const canvasId = req.nextUrl.searchParams.get('id')
|
const canvasId = req.nextUrl.searchParams.get('id')
|
||||||
console.log('[Slides API] Request for id:', canvasId)
|
|
||||||
|
|
||||||
if (!canvasId) {
|
if (!canvasId) {
|
||||||
console.log('[Slides API] ERROR: Missing id')
|
|
||||||
return new Response(JSON.stringify({ error: 'Missing id' }), {
|
return new Response(JSON.stringify({ error: 'Missing id' }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -19,39 +17,31 @@ export async function GET(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.log('[Slides API] ERROR: Canvas not found for id:', canvasId)
|
|
||||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Slides API] Canvas found:', canvas.name, '| data length:', canvas.data?.length)
|
|
||||||
console.log('[Slides API] Raw data start:', canvas.data?.substring(0, 200))
|
|
||||||
|
|
||||||
let parsed: any
|
let parsed: any
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(canvas.data)
|
parsed = JSON.parse(canvas.data)
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.log('[Slides API] ERROR: JSON parse failed:', parseErr)
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid data' }), {
|
return new Response(JSON.stringify({ error: 'Invalid data' }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Slides API] Parsed type:', parsed.type, '| has html:', !!parsed.html, '| html length:', parsed.html?.length)
|
|
||||||
console.log('[Slides API] HTML start:', parsed.html?.substring(0, 150))
|
|
||||||
|
|
||||||
if (parsed.type !== 'slides' || !parsed.html) {
|
if (parsed.type !== 'slides' || !parsed.html) {
|
||||||
console.log('[Slides API] ERROR: Not a slides canvas. type:', parsed.type, 'html exists:', !!parsed.html)
|
|
||||||
return new Response(JSON.stringify({ error: 'Not a slides canvas' }), {
|
return new Response(JSON.stringify({ error: 'Not a slides canvas' }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Slides API] SUCCESS: Returning HTML, length:', parsed.html.length)
|
|
||||||
|
|
||||||
return new Response(parsed.html, {
|
return new Response(parsed.html, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { streamText, UIMessage, stepCountIs } from 'ai'
|
import { streamText, UIMessage, stepCountIs } from 'ai'
|
||||||
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
|
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 { getSystemConfig } from '@/lib/config'
|
||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||||
@@ -9,12 +9,14 @@ import { auth } from '@/auth'
|
|||||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { 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 { ByokUnavailableError } from '@/lib/byok'
|
||||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||||
|
import { rateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export const maxDuration = 60
|
export const maxDuration = 60
|
||||||
|
|
||||||
@@ -53,6 +55,14 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const rl = await rateLimit(userId, 'chat', 15, 60)
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return new Response(JSON.stringify({ error: 'rate_limited' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '60' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GDPR AI Consent check
|
// GDPR AI Consent check
|
||||||
if (!(await hasUserAiConsent())) {
|
if (!(await hasUserAiConsent())) {
|
||||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||||
@@ -63,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)
|
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||||
try {
|
try {
|
||||||
const sysConfigEarly = await getSystemConfig()
|
await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
|
||||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
|
||||||
if (!willUseByok) {
|
|
||||||
await reserveUsageOrThrow(userId, 'chat')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
return Response.json(err.toJSON(), { status: 402 })
|
return Response.json(err.toJSON(), { status: 402 })
|
||||||
@@ -153,14 +159,14 @@ Fais un résumé concis (max 200 mots) de cette conversation. Garde les informat
|
|||||||
})
|
})
|
||||||
|
|
||||||
messagesForModel = [
|
messagesForModel = [
|
||||||
{ role: 'system' as const, content: `Contexte de la conversation précédente:\n${newSummary.slice(0, 1000)}` },
|
{ role: 'system', content: `Contexte de la conversation précédente:\n${newSummary.slice(0, 1000)}` },
|
||||||
...incomingMessages.slice(-RECENT_KEEP),
|
...incomingMessages.slice(-RECENT_KEEP),
|
||||||
]
|
] as any
|
||||||
} else if (conversation.summary) {
|
} else if (conversation.summary) {
|
||||||
messagesForModel = [
|
messagesForModel = [
|
||||||
{ role: 'system' as const, content: `Contexte de la conversation précédente:\n${conversation.summary}` },
|
{ role: 'system', content: `Contexte de la conversation précédente:\n${conversation.summary}` },
|
||||||
...incomingMessages.slice(-RECENT_KEEP),
|
...incomingMessages.slice(-RECENT_KEEP),
|
||||||
]
|
] as any
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Chat] Summary generation failed, using full context:', e)
|
console.error('[Chat] Summary generation failed, using full context:', e)
|
||||||
|
|||||||
@@ -18,10 +18,63 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const userId = session.user.id
|
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)
|
// Check for stored results (even if stale/périmés)
|
||||||
const stored = await clusteringService.getStoredClusters(userId)
|
const stored = await clusteringService.getStoredClusters(userId)
|
||||||
|
const lite = request.nextUrl.searchParams.get('lite') === '1'
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const cached = stored.clusters
|
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
|
// Fetch notes with their cluster assignments
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: { userId, trashedAt: null },
|
where: { userId, trashedAt: null },
|
||||||
@@ -66,8 +119,11 @@ export async function GET(request: NextRequest) {
|
|||||||
noteId: b.noteId,
|
noteId: b.noteId,
|
||||||
bridgeScore: b.bridgeScore,
|
bridgeScore: b.bridgeScore,
|
||||||
clustersConnected,
|
clustersConnected,
|
||||||
clusterNames: clustersConnected.map(
|
clusterNames: clustersConnected.map(cid =>
|
||||||
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
|
clusteringService.displayName(
|
||||||
|
cached.find(c => c.clusterId === cid)?.name,
|
||||||
|
cid,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
note: bridgeNoteDetailsMap.get(b.noteId)
|
note: bridgeNoteDetailsMap.get(b.noteId)
|
||||||
}
|
}
|
||||||
@@ -153,12 +209,10 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Generate cluster names with AI
|
// 2. Name from in-memory members (DB members do not exist until save)
|
||||||
for (const cluster of results.clusters) {
|
await clusteringService.nameClustersFromResults(userId, results)
|
||||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Save clustering results
|
// 3. Save clustering results (with real names)
|
||||||
await clusteringService.saveClusteringResults(userId, results)
|
await clusteringService.saveClusteringResults(userId, results)
|
||||||
|
|
||||||
// 4. Detect and save bridge notes
|
// 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 })
|
||||||
|
}
|
||||||
@@ -47,14 +47,12 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: true, executed: 0 })
|
return NextResponse.json({ success: true, executed: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[CronAgents] Found ${dueAgents.length} due agent(s): ${dueAgents.map(a => a.id).join(', ')}`)
|
|
||||||
|
|
||||||
const results: { id: string; success: boolean; error?: string }[] = []
|
const results: { id: string; success: boolean; error?: string }[] = []
|
||||||
|
|
||||||
// Execute agents sequentially (max 3 per cycle)
|
// Execute agents sequentially (max 3 per cycle)
|
||||||
for (const agent of dueAgents.slice(0, 3)) {
|
for (const agent of dueAgents.slice(0, 3)) {
|
||||||
try {
|
try {
|
||||||
console.log(`[CronAgents] Executing agent ${agent.id} (${agent.frequency})`)
|
|
||||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||||
const result = await executeAgent(agent.id, agent.userId)
|
const result = await executeAgent(agent.id, agent.userId)
|
||||||
|
|
||||||
@@ -66,7 +64,6 @@ export async function POST(request: NextRequest) {
|
|||||||
timezone: agent.timezone,
|
timezone: agent.timezone,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`[CronAgents] Agent ${agent.id} done. success=${result.success}, nextRun=${nextRun?.toISOString() ?? 'null'}`)
|
|
||||||
|
|
||||||
await prisma.agent.update({
|
await prisma.agent.update({
|
||||||
where: { id: agent.id },
|
where: { id: agent.id },
|
||||||
|
|||||||
@@ -102,10 +102,8 @@ export async function GET(request: NextRequest) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate cluster names
|
// Name from in-memory members (before save — generateClusterName needs DB rows)
|
||||||
for (const cluster of clusterResults.clusters) {
|
await clusteringService.nameClustersFromResults(user.id, clusterResults)
|
||||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save results
|
// Save results
|
||||||
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||||
@@ -174,9 +172,9 @@ export async function POST(request: NextRequest) {
|
|||||||
if (userId) {
|
if (userId) {
|
||||||
// Process specific user
|
// Process specific user
|
||||||
const clusterResults = await clusteringService.clusterNotes(userId)
|
const clusterResults = await clusteringService.clusterNotes(userId)
|
||||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
await clusteringService.nameClustersFromResults(userId, clusterResults)
|
||||||
|
|
||||||
await clusteringService.saveClusteringResults(userId, clusterResults)
|
await clusteringService.saveClusteringResults(userId, clusterResults)
|
||||||
|
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -202,9 +200,9 @@ export async function POST(request: NextRequest) {
|
|||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
try {
|
try {
|
||||||
const clusterResults = await clusteringService.clusterNotes(user.id)
|
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)
|
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||||
|
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
|
||||||
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
|
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
|
||||||
|
|
||||||
results.processed++
|
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`),
|
redis.get(`${key}:tokens`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const periodStart = new Date(`${period}-01`);
|
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
|
||||||
const periodEnd = new Date(
|
const periodEnd = new Date(Date.UTC(
|
||||||
periodStart.getFullYear(),
|
periodStart.getUTCFullYear(),
|
||||||
periodStart.getMonth() + 1,
|
periodStart.getUTCMonth() + 1,
|
||||||
1,
|
1,
|
||||||
);
|
));
|
||||||
|
|
||||||
await prisma.usageLog.upsert({
|
await prisma.usageLog.upsert({
|
||||||
where: {
|
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 })
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ export async function GET(request: NextRequest) {
|
|||||||
labelRelations: { select: { id: true } },
|
labelRelations: { select: { id: true } },
|
||||||
notebook: { select: { id: true, name: true } },
|
notebook: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
|
take: 500,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||||
|
|||||||
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,17 +1,64 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { redis } from '@/lib/redis'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const url = req.nextUrl.searchParams.get('url')
|
const url = req.nextUrl.searchParams.get('url')
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing url' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `linkpreview:${session.user.id}:${Buffer.from(url).toString('base64url').slice(0, 80)}`
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(JSON.parse(cached), {
|
||||||
|
headers: { 'Cache-Control': 'public, s-maxage=86400' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname
|
||||||
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname.startsWith('10.') ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('169.254.') ||
|
||||||
|
hostname.startsWith('172.16.') ||
|
||||||
|
hostname.startsWith('172.17.') ||
|
||||||
|
hostname.startsWith('172.18.') ||
|
||||||
|
hostname.startsWith('172.19.') ||
|
||||||
|
hostname.startsWith('172.20.') ||
|
||||||
|
hostname.startsWith('172.21.') ||
|
||||||
|
hostname.startsWith('172.22.') ||
|
||||||
|
hostname.startsWith('172.23.') ||
|
||||||
|
hostname.startsWith('172.24.') ||
|
||||||
|
hostname.startsWith('172.25.') ||
|
||||||
|
hostname.startsWith('172.26.') ||
|
||||||
|
hostname.startsWith('172.27.') ||
|
||||||
|
hostname.startsWith('172.28.') ||
|
||||||
|
hostname.startsWith('172.29.') ||
|
||||||
|
hostname.startsWith('172.30.') ||
|
||||||
|
hostname.startsWith('172.31.') ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname.endsWith('.local') ||
|
||||||
|
hostname.endsWith('.internal')
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Blocked' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||||
|
|
||||||
@@ -33,6 +80,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const html = await res.text()
|
const html = await res.text()
|
||||||
const data = extractMetadata(html, parsed)
|
const data = extractMetadata(html, parsed)
|
||||||
|
|
||||||
|
try { await redis.setex(cacheKey, 300, JSON.stringify(data)) } catch {}
|
||||||
|
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data, {
|
||||||
headers: { 'Cache-Control': 'public, s-maxage=86400' },
|
headers: { 'Cache-Control': 'public, s-maxage=86400' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ export const dynamic = 'force-dynamic'
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
// Secure endpoint with bearer token (METRICS_TOKEN env var)
|
// Secure endpoint with bearer token (METRICS_TOKEN env var)
|
||||||
const metricsToken = process.env.METRICS_TOKEN
|
const metricsToken = process.env.METRICS_TOKEN
|
||||||
if (metricsToken) {
|
if (!metricsToken) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
const authHeader = req.headers.get('authorization') ?? ''
|
const authHeader = req.headers.get('authorization') ?? ''
|
||||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
||||||
if (token !== metricsToken) {
|
if (token !== metricsToken) {
|
||||||
return new NextResponse('Unauthorized', { status: 401 })
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const MODE_MAP: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_gra
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { text, mode = 'improve' } = await req.json().catch(() => ({}))
|
const { text, mode = 'improve' } = await req.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
|||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { content } = await req.json().catch(() => ({}))
|
const { content } = await req.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const formData = await req.formData().catch(() => null)
|
const formData = await req.formData().catch(() => null)
|
||||||
@@ -18,6 +19,15 @@ export async function POST(req: NextRequest) {
|
|||||||
const apiKey = config.OPENAI_API_KEY
|
const apiKey = config.OPENAI_API_KEY
|
||||||
if (!apiKey) return NextResponse.json({ error: 'Service non disponible' }, { status: 503 })
|
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()
|
const whisperForm = new FormData()
|
||||||
whisperForm.append('file', file, 'audio.m4a')
|
whisperForm.append('file', file, 'audio.m4a')
|
||||||
whisperForm.append('model', 'whisper-1')
|
whisperForm.append('model', 'whisper-1')
|
||||||
|
|||||||
19
memento-note/app/api/mobile/auth/logout/route.ts
Normal file
19
memento-note/app/api/mobile/auth/logout/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
import { redis } from '@/lib/redis'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const userId = await getMobileUserId(req)
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const auth = req.headers.get('Authorization') ?? ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await redis.setex(`mobile:revoked:${token}`, 90 * 24 * 60 * 60, '1')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
|||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}))
|
const body = await req.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getMobileUserId } from '@/lib/mobile-auth'
|
|||||||
import { computeSm2Update } from '@/lib/flashcards/sm2'
|
import { computeSm2Update } from '@/lib/flashcards/sm2'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export async function GET(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -33,7 +33,7 @@ export async function PUT(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -61,7 +61,7 @@ export async function DELETE(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function getTodayContent(title: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const todayKey = getTodayKey()
|
const todayKey = getTodayKey()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
@@ -43,7 +43,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { title, content, notebookId } = await req.json().catch(() => ({}))
|
const { title, content, notebookId } = await req.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const userId = getMobileUserId(req)
|
const userId = await getMobileUserId(req)
|
||||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export async function PATCH(
|
|||||||
if (color !== undefined) updateData.color = color
|
if (color !== undefined) updateData.color = color
|
||||||
if (order !== undefined) updateData.order = order
|
if (order !== undefined) updateData.order = order
|
||||||
if (trashedAt !== undefined) updateData.trashedAt = trashedAt ? new Date(trashedAt) : null
|
if (trashedAt !== undefined) updateData.trashedAt = trashedAt ? new Date(trashedAt) : null
|
||||||
if (parentId !== undefined) updateData.parentId = parentId
|
if (parentId !== undefined) updateData.parent = parentId ? { connect: { id: parentId } } : { disconnect: true }
|
||||||
|
|
||||||
if (trashedAt !== undefined) {
|
if (trashedAt !== undefined) {
|
||||||
const descendantIds = await getDescendantIds(id)
|
const descendantIds = await getDescendantIds(id)
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ export async function GET(
|
|||||||
console.error('[network] NoteLink query failed:', err)
|
console.error('[network] NoteLink query failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapBacklink = (bl: (typeof backlinks)[number]) => ({
|
const mapBacklink = (bl: any) => ({
|
||||||
id: bl.id,
|
id: bl.id,
|
||||||
note: bl.sourceNote,
|
note: bl.sourceNote,
|
||||||
contextSnippet: bl.contextSnippet,
|
contextSnippet: bl.contextSnippet,
|
||||||
createdAt: bl.createdAt,
|
createdAt: bl.createdAt,
|
||||||
})
|
})
|
||||||
const mapOutbound = (ol: (typeof outbound)[number]) => ({
|
const mapOutbound = (ol: any) => ({
|
||||||
id: ol.id,
|
id: ol.id,
|
||||||
note: ol.targetNote,
|
note: ol.targetNote,
|
||||||
contextSnippet: ol.contextSnippet,
|
contextSnippet: ol.contextSnippet,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export async function POST(req: NextRequest) {
|
|||||||
links: n.links ?? undefined,
|
links: n.links ?? undefined,
|
||||||
trashedAt: parseDate(n.trashedAt),
|
trashedAt: parseDate(n.trashedAt),
|
||||||
notebookId: n.notebookId ?? null,
|
notebookId: n.notebookId ?? null,
|
||||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
labelRelations: {
|
labelRelations: {
|
||||||
set: validLabelIds.map(id => ({ id })),
|
set: validLabelIds.map(id => ({ id })),
|
||||||
@@ -187,7 +187,7 @@ export async function POST(req: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||||
labelRelations: {
|
labelRelations: {
|
||||||
connect: validLabelIds.map(id => ({ id })),
|
connect: validLabelIds.map(id => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -211,7 +211,7 @@ export async function POST(req: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
stats.notes++
|
stats.notes++
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Store as a notification to the note owner + admin
|
// Store as a notification to the note owner + admin
|
||||||
await prisma.notification.create({
|
await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
userId: note.userId,
|
userId: note.userId!,
|
||||||
type: 'content_report',
|
type: 'content_report',
|
||||||
title: `Signalement : ${reason}`,
|
title: `Signalement : ${reason}`,
|
||||||
message: details || `Un visiteur a signalé votre note publiée pour: ${reason}`,
|
message: details || `Un visiteur a signalé votre note publiée pour: ${reason}`,
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (content !== undefined) updateData.content = content
|
if (content !== undefined) updateData.content = content
|
||||||
if (color !== undefined) updateData.color = color
|
if (color !== undefined) updateData.color = color
|
||||||
if (type !== undefined) updateData.type = type
|
if (type !== undefined) updateData.type = type
|
||||||
if (checkItems !== undefined) updateData.checkItems = checkItems ?? null
|
if (checkItems !== undefined) updateData.checkItems = checkItems != null ? JSON.stringify(checkItems) : null
|
||||||
if (labels !== undefined) updateData.labels = labels ?? null
|
if (labels !== undefined) updateData.labels = labels != null ? JSON.stringify(labels) : null
|
||||||
if (isPinned !== undefined) updateData.isPinned = isPinned
|
if (isPinned !== undefined) updateData.isPinned = isPinned
|
||||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||||
if (images !== undefined) updateData.images = images ?? null
|
if (images !== undefined) updateData.images = images ?? null
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||||
|
|
||||||
console.log(`[verify] ${provider}: fromApi=${result.fromApi}, models=${result.models.length}`, result.models);
|
|
||||||
|
|
||||||
// Only consider key valid if we got models from the REAL API (not fallbacks)
|
// Only consider key valid if we got models from the REAL API (not fallbacks)
|
||||||
// Exception: Anthropic/Google/custom don't guarantee public /models endpoints, so we accept fallbacks for them
|
// Exception: Anthropic/Google/custom don't guarantee public /models endpoints, so we accept fallbacks for them
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function GET() {
|
|||||||
})),
|
})),
|
||||||
brainstorms: brainstorms.map((b) => ({
|
brainstorms: brainstorms.map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
title: b.title,
|
title: b.seedIdea,
|
||||||
createdAt: b.createdAt,
|
createdAt: b.createdAt,
|
||||||
ideasCount: b.ideas.length,
|
ideasCount: b.ideas.length,
|
||||||
})),
|
})),
|
||||||
@@ -196,9 +196,9 @@ export async function GET() {
|
|||||||
|
|
||||||
const canvasFolder = zip.folder('canvases')!
|
const canvasFolder = zip.folder('canvases')!
|
||||||
for (const brainstorm of brainstorms) {
|
for (const brainstorm of brainstorms) {
|
||||||
const fileBase = uniqueBaseName(brainstorm.title || 'Untitled Brainstorm', brainstorm.id)
|
const fileBase = uniqueBaseName(brainstorm.seedIdea || 'Untitled Brainstorm', brainstorm.id)
|
||||||
|
|
||||||
let canvasMd = `# Brainstorm Canvas: ${brainstorm.title || 'Untitled'}\n\n`
|
let canvasMd = `# Brainstorm Canvas: ${brainstorm.seedIdea || 'Untitled'}\n\n`
|
||||||
canvasMd += `- **Created At:** ${brainstorm.createdAt.toISOString()}\n`
|
canvasMd += `- **Created At:** ${brainstorm.createdAt.toISOString()}\n`
|
||||||
canvasMd += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n`
|
canvasMd += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n`
|
||||||
canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n`
|
canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n`
|
||||||
@@ -232,7 +232,7 @@ export async function GET() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const offlineCanvases = brainstorms.map((b) => ({
|
const offlineCanvases = brainstorms.map((b) => ({
|
||||||
title: b.title || 'Untitled Brainstorm',
|
title: b.seedIdea || 'Untitled Brainstorm',
|
||||||
createdAt: b.createdAt.toISOString(),
|
createdAt: b.createdAt.toISOString(),
|
||||||
ideas: b.ideas.map((i) => ({
|
ideas: b.ideas.map((i) => ({
|
||||||
title: i.title,
|
title: i.title,
|
||||||
@@ -499,7 +499,7 @@ export async function GET() {
|
|||||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||||
|
|
||||||
const dateString = new Date().toISOString().split('T')[0]
|
const dateString = new Date().toISOString().split('T')[0]
|
||||||
return new NextResponse(zipBuffer, {
|
return new NextResponse(new Uint8Array(zipBuffer) as BodyInit, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`,
|
'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`,
|
||||||
|
|||||||
@@ -6,6 +6,25 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
|
||||||
|
|
||||||
|
/* CRITICAL: prefers-reduced-motion — désactive toutes les animations */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRITICAL: focus-visible ring global sur tous les éléments interactifs */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom breakpoints for desktop design (matching code.html reference) */
|
/* Custom breakpoints for desktop design (matching code.html reference) */
|
||||||
@theme {
|
@theme {
|
||||||
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default async function RootLayout({
|
|||||||
data-server-accent={serverAccent}
|
data-server-accent={serverAccent}
|
||||||
style={htmlStyle}
|
style={htmlStyle}
|
||||||
>
|
>
|
||||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
|
<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="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
||||||
<Script id="direction-init" src="/scripts/direction-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" />
|
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
|
||||||
@@ -107,7 +107,7 @@ export default async function RootLayout({
|
|||||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
||||||
{children}
|
{children}
|
||||||
<LanguageProvider initialLanguage="en">
|
<LanguageProvider initialLanguage="en">
|
||||||
<CookieConsentRoot initialAnonymousAnalytics={aiSettings?.anonymousAnalytics} />
|
<CookieConsentRoot />
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</SessionProviderWrapper>
|
</SessionProviderWrapper>
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
|||||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
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' },
|
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||||
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
|
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
|
||||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
|
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
|
||||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
|
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
|
||||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
|
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
|
||||||
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
|
custom: { name: 'Custom API', hint: 'Your own endpoint' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(provider: string): string {
|
function displayName(provider: string): string {
|
||||||
@@ -59,7 +59,7 @@ type SavedKey = {
|
|||||||
|
|
||||||
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
|
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
|
||||||
const res = await fetch('/api/user/api-keys')
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,8 @@ function EditKeyForm({
|
|||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
|
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 needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
|
||||||
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
|
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
|
||||||
|
|
||||||
@@ -118,10 +120,10 @@ function EditKeyForm({
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const body = await res.json().catch(() => ({}))
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
|
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
|
||||||
onInvalidate()
|
onInvalidate()
|
||||||
onDone()
|
onDone()
|
||||||
},
|
},
|
||||||
@@ -132,7 +134,7 @@ function EditKeyForm({
|
|||||||
const keyToUse = newKey.length >= 8 ? newKey : ''
|
const keyToUse = newKey.length >= 8 ? newKey : ''
|
||||||
if (!keyToUse && !model) return
|
if (!keyToUse && !model) return
|
||||||
if (!keyToUse) {
|
if (!keyToUse) {
|
||||||
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
|
toast.info(t('byok.testHint'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
@@ -143,7 +145,7 @@ function EditKeyForm({
|
|||||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||||
setTestResult(await res.json())
|
setTestResult(await res.json())
|
||||||
} catch {
|
} catch {
|
||||||
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
|
setTestResult({ ok: false, error: t('byok.contactError') })
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
@@ -155,18 +157,18 @@ function EditKeyForm({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Alias */}
|
{/* Alias */}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
|
<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="ex. Ma clé pro" />
|
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
{needsUrl && (
|
{needsUrl && (
|
||||||
<div className="space-y-1">
|
<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" />
|
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,21 +176,21 @@ function EditKeyForm({
|
|||||||
|
|
||||||
{/* Model */}
|
{/* Model */}
|
||||||
<div className="space-y-1">
|
<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 ? (
|
{showModelDropdown ? (
|
||||||
<Select value={model} onValueChange={setModel}>
|
<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>
|
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : showModelInput ? (
|
) : showModelInput ? (
|
||||||
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Key rotation (optional) */}
|
{/* Key rotation (optional) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[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>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`edit-key-${savedKey.provider}`}
|
id={`edit-key-${savedKey.provider}`}
|
||||||
@@ -196,7 +198,7 @@ function EditKeyForm({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={newKey}
|
value={newKey}
|
||||||
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
|
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
|
||||||
placeholder="sk-... (optionnel)"
|
placeholder={t('byok.keyPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />}
|
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||||
<span>
|
<span>
|
||||||
{testResult.ok
|
{testResult.ok
|
||||||
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
|
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
|
||||||
: testResult.error ?? 'Échec'
|
: testResult.error ?? t('common.error')
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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"
|
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} />}
|
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||||
Tester
|
{t('byok.test')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +235,7 @@ function EditKeyForm({
|
|||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
className="flex-1 py-2 rounded-lg text-[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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
|
|||||||
|
|
||||||
async function verifyKey() {
|
async function verifyKey() {
|
||||||
if (!provider || apiKey.length < 8) return
|
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)
|
setVerifying(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
|
|||||||
if (res.ok && body.valid) {
|
if (res.ok && body.valid) {
|
||||||
setKeyOk(true)
|
setKeyOk(true)
|
||||||
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
|
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 {
|
} else {
|
||||||
setKeyOk(false)
|
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) }
|
finally { setVerifying(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
|
|||||||
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
||||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||||
setTestResult(await res.json())
|
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) }
|
finally { setTesting(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const body = await res.json().catch(() => ({}))
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
|
toast.success(t('byok.keySaved', { name: displayName(provider) }))
|
||||||
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
|
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
|
||||||
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
|
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
|
||||||
invalidate()
|
invalidate()
|
||||||
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
|
|||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ isActive }),
|
body: JSON.stringify({ isActive }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Erreur')
|
if (!res.ok) throw new Error(t('common.error'))
|
||||||
},
|
},
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
onError: () => toast.error(t('byokSettings.error')),
|
onError: () => toast.error(t('byokSettings.error')),
|
||||||
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (p: string) => {
|
mutationFn: async (p: string) => {
|
||||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
|
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')),
|
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'
|
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||||
)}>
|
)}>
|
||||||
{activeKey ? (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
|
|||||||
{/* Saved keys */}
|
{/* Saved keys */}
|
||||||
{keys.length > 0 && (
|
{keys.length > 0 && (
|
||||||
<div className="space-y-2">
|
<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">
|
<ul className="space-y-1">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<li key={key.provider}>
|
<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.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>}
|
{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')}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
|
|||||||
type="button"
|
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"
|
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}
|
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} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
|
|||||||
{/* Add key form */}
|
{/* Add key form */}
|
||||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
<p className="text-[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>
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<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}>
|
<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>
|
<SelectContent>
|
||||||
{allowed.map((p) => (
|
{allowed.map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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>
|
<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="ex. Ma clé pro" disabled={saveMutation.isPending} />
|
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{NEEDS_BASE_URL.has(provider) && (
|
{NEEDS_BASE_URL.has(provider) && (
|
||||||
<div className="space-y-1.5">
|
<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} />
|
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<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',
|
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')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{provider && (
|
{provider && (
|
||||||
<div className="space-y-1.5">
|
<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 ? (
|
{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 ? (
|
) : showModelDropdown ? (
|
||||||
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
|
<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>
|
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : showModelInput ? (
|
) : 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" />}
|
{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">
|
<div className="space-y-0.5">
|
||||||
{testResult.ok ? (
|
{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">{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 ?? 'Échec du test'}</p>}
|
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
|
|||||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[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>
|
||||||
<Button
|
<Button
|
||||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||||
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
|
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export interface BlockSuggestion {
|
export interface BlockSuggestion {
|
||||||
blockId: string
|
blockId: string
|
||||||
@@ -22,6 +23,7 @@ interface BlockPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
|
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
|
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
|
||||||
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
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"
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function getActionLabel(action: string, t: (key: string) => string | undefined):
|
|||||||
function timeAgo(dateStr: string, t: (key: string) => string | undefined): string {
|
function timeAgo(dateStr: string, t: (key: string) => string | undefined): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime()
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
const mins = Math.floor(diff / 60000)
|
const mins = Math.floor(diff / 60000)
|
||||||
if (mins < 1) return t('brainstorm.justNow')
|
if (mins < 1) return t('brainstorm.justNow') || ''
|
||||||
if (mins < 60) return `${mins}m`
|
if (mins < 60) return `${mins}m`
|
||||||
const hours = Math.floor(mins / 60)
|
const hours = Math.floor(mins / 60)
|
||||||
if (hours < 24) return `${hours}h`
|
if (hours < 24) return `${hours}h`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { Zap, Lightbulb, Trophy } from 'lucide-react'
|
import { Zap, Lightbulb, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
|
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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="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">
|
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||||
<Trophy size={14} />
|
<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>
|
||||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
<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">
|
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||||
<Lightbulb size={14} />
|
<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>
|
||||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
|
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center gap-2 mb-6 px-1">
|
||||||
<Zap size={16} className="text-ochre" />
|
<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>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{bridgeNotes.map((bridge) => (
|
{bridgeNotes.map((bridge) => (
|
||||||
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
<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>
|
</h4>
|
||||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{bridgeNotes.length === 0 && (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center gap-2 mb-6 px-1">
|
||||||
<Lightbulb size={16} className="text-indigo-500" />
|
<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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{suggestions.map((s, idx) => (
|
{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 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>
|
</div>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
<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
|
<button
|
||||||
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
|
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"
|
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>
|
</button>
|
||||||
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
{suggestions.length === 0 && !loading && (
|
{suggestions.length === 0 && !loading && (
|
||||||
<div className="text-center py-8 text-concrete">
|
<div className="text-center py-8 text-concrete">
|
||||||
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">No connection suggestions yet</p>
|
<p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
|
||||||
<p className="text-xs mt-1">All your clusters may already be connected!</p>
|
<p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={generateNewSuggestions}
|
onClick={generateNewSuggestions}
|
||||||
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</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 { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
|
||||||
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
|
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
// Chart type to color mapping for visual variety
|
// Chart type to color mapping for visual variety
|
||||||
const CHART_TYPE_COLORS: Record<string, string> = {
|
const CHART_TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onSelectChart,
|
onSelectChart,
|
||||||
}: ChartSuggestionsDialogProps) {
|
}: ChartSuggestionsDialogProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
|
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
@@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Search className="w-3 h-3 animate-spin" />
|
<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>
|
</span>
|
||||||
) : response?.hasData ? (
|
) : response?.hasData ? (
|
||||||
`Found ${response?.detectedData || 'data'}`
|
`${t('chart.found')} ${response?.detectedData || ''}`
|
||||||
) : (
|
) : (
|
||||||
'No data detected'
|
t('chart.noDataDetected')
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : response?.quotaExceeded ? (
|
) : response?.quotaExceeded ? (
|
||||||
@@ -167,7 +169,7 @@ export function ChartSuggestionsDialog({
|
|||||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||||
onClick={() => (window.location.href = '/settings/billing')}
|
onClick={() => (window.location.href = '/settings/billing')}
|
||||||
>
|
>
|
||||||
Upgrade Plan
|
{t('ai.featureLocked')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
<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>
|
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
|
||||||
<details className="text-left text-xs text-muted-foreground mt-4">
|
<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">
|
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
|
||||||
analyzedText: {response.analyzedText}
|
analyzedText: {response.analyzedText}
|
||||||
{'\n'}detectedData: {response.detectedData}
|
{'\n'}detectedData: {response.detectedData}
|
||||||
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
<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">
|
<p className="text-muted-foreground mb-4">
|
||||||
Try adding numerical data to your note. Charts work best with:
|
{t('chart.noDataHint')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-muted-foreground text-left inline-block">
|
<ul className="text-sm text-muted-foreground text-left inline-block">
|
||||||
<li>• Sales figures, metrics, or measurements</li>
|
<li>{t('chart.dataHint1')}</li>
|
||||||
<li>• Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
|
<li>{t('chart.dataHint2')}</li>
|
||||||
<li>• Percentages or proportions</li>
|
<li>{t('chart.dataHint3')}</li>
|
||||||
<li>• Time-series data</li>
|
<li>{t('chart.dataHint4')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-4 pt-4 border-t border-border/50">
|
<div className="mt-4 pt-4 border-t border-border/50">
|
||||||
<details className="text-left">
|
<details className="text-left">
|
||||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||||
Debug: Show what was analyzed
|
{t('chart.debugShowAnalyzed')}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
|
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
|
||||||
{textToAnalyze.substring(0, 500)}
|
{textToAnalyze.substring(0, 500)}
|
||||||
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
|
|||||||
{/* Data preview */}
|
{/* Data preview */}
|
||||||
<div className="mt-2 pt-2 border-t border-border/50">
|
<div className="mt-2 pt-2 border-t border-border/50">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{suggestion.data.length} data points
|
{suggestion.data.length} {t('chart.dataPoints')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({
|
|||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
|
||||||
<span>← → Navigate</span>
|
<span>{t('chart.navigateHint')}</span>
|
||||||
<span>↵ Select</span>
|
<span>{t('chart.selectHint')}</span>
|
||||||
<span>Esc Cancel</span>
|
<span>{t('chart.escCancel')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Node,
|
Node,
|
||||||
Edge,
|
Edge,
|
||||||
@@ -47,6 +48,7 @@ export function ClusterVisualization({
|
|||||||
bridgeNotes,
|
bridgeNotes,
|
||||||
onNodeClick
|
onNodeClick
|
||||||
}: ClusterVisualizationProps) {
|
}: ClusterVisualizationProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||||
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
|
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
|
||||||
@@ -79,8 +81,8 @@ export function ClusterVisualization({
|
|||||||
data: {
|
data: {
|
||||||
label: (
|
label: (
|
||||||
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
|
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
|
||||||
{cluster.name || `Cluster ${cluster.clusterId}`}
|
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
|
||||||
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
|
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<p>No clusters to display</p>
|
<p>{t('clusters.empty')}</p>
|
||||||
<p className="text-sm mt-2">Create more notes to generate clusters</p>
|
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -200,10 +202,10 @@ export function ClusterVisualization({
|
|||||||
{selectedCluster !== null && (
|
{selectedCluster !== null && (
|
||||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
||||||
<h3 className="font-semibold mb-2">
|
<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>
|
</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,11 +214,11 @@ export function ClusterVisualization({
|
|||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
||||||
<span>Regular note</span>
|
<span>{t('clusters.regularNote')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Tag as TagIcon, RefreshCw,
|
Tag as TagIcon, RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
@@ -123,8 +124,10 @@ interface ContextualAIChatProps {
|
|||||||
noteContent?: string
|
noteContent?: string
|
||||||
noteImages?: string[]
|
noteImages?: string[]
|
||||||
noteId?: string
|
noteId?: string
|
||||||
/** Called when an action result should be injected into the note */
|
/** Called when an action result should be injected into the note.
|
||||||
onApplyToNote?: (newContent: string) => void
|
* `options.asRichText` signals that newContent is HTML and the note should
|
||||||
|
* switch out of markdown mode. */
|
||||||
|
onApplyToNote?: (newContent: string, options?: { asRichText?: boolean }) => void
|
||||||
/** Called when the user wants to undo the last injected action */
|
/** Called when the user wants to undo the last injected action */
|
||||||
onUndoLastAction?: () => void
|
onUndoLastAction?: () => void
|
||||||
/** Whether the last action has been applied (so we can show undo) */
|
/** Whether the last action has been applied (so we can show undo) */
|
||||||
@@ -213,7 +216,7 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
// Action state
|
// Action state
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
const [actionPreview, setActionPreview] = useState<{ label: string; text: string; asRichText?: boolean } | null>(null)
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
const [translateTarget, setTranslateTarget] = useState('')
|
const [translateTarget, setTranslateTarget] = useState('')
|
||||||
|
|
||||||
@@ -377,7 +380,7 @@ export function ContextualAIChat({
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||||
const result = data[action.resultKey] || ''
|
const result = data[action.resultKey] || ''
|
||||||
setActionPreview({ label: t(action.i18nKey), text: result })
|
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
mToast.error(e.message || t('ai.actionError'))
|
mToast.error(e.message || t('ai.actionError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -387,7 +390,7 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
const handleApplyPreview = () => {
|
const handleApplyPreview = () => {
|
||||||
if (!actionPreview || !onApplyToNote) return
|
if (!actionPreview || !onApplyToNote) return
|
||||||
onApplyToNote(actionPreview.text)
|
onApplyToNote(actionPreview.text, { asRichText: actionPreview.asRichText })
|
||||||
setActionPreview(null)
|
setActionPreview(null)
|
||||||
mToast.success(t('ai.appliedToNote'))
|
mToast.success(t('ai.appliedToNote'))
|
||||||
}
|
}
|
||||||
@@ -712,7 +715,15 @@ export function ContextualAIChat({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
||||||
|
{actionPreview.asRichText ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm dark:prose-invert max-w-none"
|
||||||
|
dir="auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(actionPreview.text) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<MarkdownContent content={actionPreview.text} />
|
<MarkdownContent content={actionPreview.text} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||||
@@ -957,7 +968,7 @@ export function ContextualAIChat({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{ACTION_IDS.filter(a => a.id !== 'markdown').map((action, i) => {
|
{ACTION_IDS.filter(a => a.id !== 'markdown' && a.id !== 'toRichText').map((action, i) => {
|
||||||
const loading = actionLoading === action.id
|
const loading = actionLoading === action.id
|
||||||
const isActive = action.id === 'translate' && showLangPicker
|
const isActive = action.id === 'translate' && showLangPicker
|
||||||
const Icon = action.icon
|
const Icon = action.icon
|
||||||
|
|||||||
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 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
||||||
|
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardRevisionWidget({
|
||||||
|
dueCount,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
dueCount: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="revision"
|
||||||
|
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.review')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
|
||||||
|
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardStatsWidget({
|
||||||
|
clusterCount,
|
||||||
|
bridgeCount,
|
||||||
|
noteCount,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
clusterCount: number
|
||||||
|
bridgeCount: number
|
||||||
|
noteCount: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="stats"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.stats')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: t('homeDashboard.themes'), value: clusterCount },
|
||||||
|
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
|
||||||
|
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardAgentActivityWidget({
|
||||||
|
actions,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="agent-activity"
|
||||||
|
icon={<Bot size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.agent-activity')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : actions.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{actions.slice(0, 3).map(a => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
|
||||||
|
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||||
|
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGmailWidget({
|
||||||
|
connected,
|
||||||
|
recentCaptures,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
connected: boolean
|
||||||
|
recentCaptures: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="gmail"
|
||||||
|
icon={<Mail size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.gmailCaptures')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : connected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
|
||||||
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.gmailConnect')} →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPinnedWidget({
|
||||||
|
notes,
|
||||||
|
loading,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
notes: { id: string; title: string | null; notebookId: string | null }[]
|
||||||
|
loading: boolean
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="pinned"
|
||||||
|
icon={<Pin size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.pinned')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{notes.map(n => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(n.id, n.notebookId)}
|
||||||
|
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||||
|
>
|
||||||
|
{n.title || t('homeDashboard.untitled')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardActivityWidget({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
data: { date: string; count: number }[]
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const hasActivity = data.some(d => d.count > 0)
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="activity"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.activity')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-24 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
) : !hasActivity ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-4 text-center">
|
||||||
|
<p className="text-[10px] text-concrete leading-relaxed">{t('homeDashboard.activityEmptyHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RevisionHeatmap data={data} />
|
||||||
|
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardUsageWidget() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="usage"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.usage')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<UsageMeter className="px-0 py-0" />
|
||||||
|
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardFlashcardsProgressWidget({
|
||||||
|
retentionRate,
|
||||||
|
streak,
|
||||||
|
totalCards,
|
||||||
|
dueCount = 0,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
retentionRate: number
|
||||||
|
streak: number
|
||||||
|
totalCards: number
|
||||||
|
dueCount?: number
|
||||||
|
loading?: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const retention = Math.max(0, Math.min(100, retentionRate))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="flashcards-progress"
|
||||||
|
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.flashcards-progress')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : totalCards === 0 && dueCount === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3 text-center">
|
||||||
|
<p className="text-[10px] text-concrete leading-relaxed mb-2">
|
||||||
|
{t('homeDashboard.flashEmptyHint')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.flashEmptyCta')} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={onOpen} className="w-full text-start group">
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-2.5">
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retention}%</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards || dueCount}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-accent transition-all"
|
||||||
|
style={{ width: `${retention}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{dueCount > 0 ? (
|
||||||
|
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||||
|
{t('homeDashboard.flashDueCta', { count: dueCount })} →
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
|
||||||
|
{t('homeDashboard.flashOpenCta')} →
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user