Compare commits
42 Commits
1d17fe2f9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44a0f9c68 | ||
|
|
a99d1dafb2 | ||
|
|
5aa7202abe | ||
|
|
02b835fb13 | ||
|
|
d1336b1fea | ||
|
|
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/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/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/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/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/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
|
||||
}
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/subagents/a5601ff1-7934-4872-acd8-266e416c3680.jsonl": 1779998536313
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1782633053032,
|
||||
"turnsSinceLastRun": 8,
|
||||
"lastTranscriptMtimeMs": 1782633052959.9294,
|
||||
"lastProcessedGenerationId": "6d690dcb-cba6-47d2-9361-43956be8667e",
|
||||
"lastRunAtMs": 1783196565768,
|
||||
"turnsSinceLastRun": 6,
|
||||
"lastTranscriptMtimeMs": 1783196565691.0437,
|
||||
"lastProcessedGenerationId": "6ccc52d1-8073-4386-b4d0-84c60a305da2",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
@@ -91,24 +91,6 @@ jobs:
|
||||
-d "parse_mode=Markdown" || true
|
||||
fi
|
||||
|
||||
- name: Pack web artifact for deploy
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
working-directory: memento-note
|
||||
run: |
|
||||
tar czf ../web-artifact.tgz \
|
||||
.next/standalone .next/static public prisma \
|
||||
node_modules/.prisma node_modules/@prisma node_modules/prisma \
|
||||
docker-entrypoint.sh socket-server.ts tsconfig.json
|
||||
ls -lh ../web-artifact.tgz
|
||||
|
||||
- name: Upload web artifact
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: web-artifact
|
||||
path: web-artifact.tgz
|
||||
retention-days: 2
|
||||
|
||||
deploy:
|
||||
name: Deploy production (on server)
|
||||
needs: ci
|
||||
@@ -123,10 +105,32 @@ jobs:
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Download web artifact
|
||||
uses: actions/download-artifact@v3
|
||||
# Build sur docker-host : évite upload-artifact Gitea (souvent cassé en prod).
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: web-artifact
|
||||
node-version: "22"
|
||||
|
||||
- name: Build web artifact on server
|
||||
working-directory: /opt/memento/memento-note
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci --legacy-peer-deps
|
||||
npx prisma generate
|
||||
npm run build
|
||||
for p in .next/standalone .next/static public prisma; do
|
||||
[ -e "$p" ] || { echo "Missing build output: $p"; exit 1; }
|
||||
done
|
||||
tar czf /tmp/web-artifact.tgz \
|
||||
.next/standalone .next/static public prisma \
|
||||
node_modules/.prisma node_modules/@prisma node_modules/prisma \
|
||||
docker-entrypoint.sh socket-server.ts tsconfig.json
|
||||
SIZE=$(stat -c%s /tmp/web-artifact.tgz)
|
||||
if [ "$SIZE" -lt 1048576 ]; then
|
||||
echo "ERROR: artifact too small ($SIZE bytes)"
|
||||
exit 1
|
||||
fi
|
||||
ls -lh /tmp/web-artifact.tgz
|
||||
|
||||
- name: Update .env.docker
|
||||
env:
|
||||
@@ -179,7 +183,6 @@ jobs:
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
sed -i 's/\r$//' "$ENV_FILE"
|
||||
echo "## AUTO-MANAGED BY CI — do not edit manually ##" > "$ENV_FILE"
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
[ -z "$val" ] && return
|
||||
@@ -236,19 +239,9 @@ jobs:
|
||||
upsert MCP_API_KEY "$MCP_API_KEY"
|
||||
# Write metrics token file for Prometheus (same secret)
|
||||
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
|
||||
# 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
|
||||
env:
|
||||
ARTIFACT_TGZ: ${{ github.workspace }}/web-artifact.tgz
|
||||
ARTIFACT_TGZ: /tmp/web-artifact.tgz
|
||||
EXPECTED_COMMIT: ${{ github.sha }}
|
||||
run: bash /opt/memento/scripts/deploy-prod.sh
|
||||
|
||||
@@ -68,7 +68,6 @@ jobs:
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
sed -i 's/\r$//' "$ENV_FILE"
|
||||
echo "## AUTO-MANAGED BY CI — do not edit manually ##" > "$ENV_FILE"
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
[ -z "$val" ] && return
|
||||
@@ -124,15 +123,6 @@ jobs:
|
||||
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
||||
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
|
||||
# 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 (full build, no CI artifact)
|
||||
|
||||
49
.github/workflows/ci.yaml
vendored
49
.github/workflows/ci.yaml
vendored
@@ -91,25 +91,6 @@ jobs:
|
||||
-d "parse_mode=Markdown" || true
|
||||
fi
|
||||
|
||||
- name: Pack web artifact for deploy
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
working-directory: memento-note
|
||||
run: |
|
||||
tar czf ../web-artifact.tgz \
|
||||
.next/standalone .next/static public prisma \
|
||||
node_modules/.prisma node_modules/@prisma node_modules/prisma \
|
||||
docker-entrypoint.sh socket-server.ts tsconfig.json
|
||||
ls -lh ../web-artifact.tgz
|
||||
|
||||
- name: Upload web artifact
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: web-artifact
|
||||
path: web-artifact.tgz
|
||||
retention-days: 2
|
||||
|
||||
deploy:
|
||||
name: Deploy production (on server)
|
||||
needs: ci
|
||||
@@ -124,11 +105,31 @@ jobs:
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Download web artifact
|
||||
uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: web-artifact
|
||||
node-version: "22"
|
||||
|
||||
- name: Build web artifact on server
|
||||
working-directory: /opt/memento/memento-note
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci --legacy-peer-deps
|
||||
npx prisma generate
|
||||
npm run build
|
||||
for p in .next/standalone .next/static public prisma; do
|
||||
[ -e "$p" ] || { echo "Missing build output: $p"; exit 1; }
|
||||
done
|
||||
tar czf /tmp/web-artifact.tgz \
|
||||
.next/standalone .next/static public prisma \
|
||||
node_modules/.prisma node_modules/@prisma node_modules/prisma \
|
||||
docker-entrypoint.sh socket-server.ts tsconfig.json
|
||||
SIZE=$(stat -c%s /tmp/web-artifact.tgz)
|
||||
if [ "$SIZE" -lt 1048576 ]; then
|
||||
echo "ERROR: artifact too small ($SIZE bytes)"
|
||||
exit 1
|
||||
fi
|
||||
ls -lh /tmp/web-artifact.tgz
|
||||
|
||||
- name: Update .env.docker
|
||||
env:
|
||||
@@ -229,6 +230,6 @@ jobs:
|
||||
|
||||
- name: Deploy on 192.168.1.190
|
||||
env:
|
||||
ARTIFACT_TGZ: ${{ github.workspace }}/web-artifact.tgz
|
||||
ARTIFACT_TGZ: /tmp/web-artifact.tgz
|
||||
EXPECTED_COMMIT: ${{ github.sha }}
|
||||
run: bash /opt/memento/scripts/deploy-prod.sh
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -5,14 +5,14 @@
|
||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
|
||||
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle.
|
||||
- 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 responsive** (`components/sidebar.tsx`) — `w-80` (320px) de base, plus large en `xl`/`2xl` (grands écrans) ; garder le fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur ces largeurs (rail fixe 54px) ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
|
||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. **CI/CD Gitea spécifique** : (1) `actions/upload-artifact` et `download-artifact` doivent utiliser **@v3** (pas @v4 — non supporté par Gitea, erreur `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; **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. **CI/CD Gitea spécifique** : (1) `actions/upload-artifact` et `download-artifact` doivent utiliser **@v3** (pas @v4 — non supporté par Gitea, erreur `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — chaque push sur `main` déclenche un déploiement automatique en production.
|
||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée).
|
||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande.
|
||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire »).
|
||||
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md`.
|
||||
|
||||
## Learned Workspace Facts
|
||||
@@ -26,6 +26,6 @@
|
||||
- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
|
||||
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
||||
- 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).
|
||||
- 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, 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tabs } from 'expo-router'
|
||||
import { BookOpen, Search, Home, User, GraduationCap } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
@@ -13,11 +14,11 @@ export default function TabsLayout() {
|
||||
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="notebooks" options={{ title: 'Carnets', 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="search" options={{ title: 'Recherche', 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="home" options={{ title: t('tab.home'), tabBarIcon: ({ color, size }) => <Home 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: t('tab.revision'), tabBarIcon: ({ color, size }) => <GraduationCap 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: t('tab.profile'), tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ export default function NoteScreen() {
|
||||
const handleEdit = () => {
|
||||
if (!note) return
|
||||
setEditTitle(note.title)
|
||||
setEditContent(htmlToPlainText(note.content ?? ''))
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
@@ -168,10 +167,10 @@ export default function NoteScreen() {
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
||||
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')
|
||||
setNote((prev) => prev ? { ...prev, title: editTitle.trim(), content: editContent } : prev)
|
||||
setNote((prev) => prev ? { ...prev, title: editTitle.trim() } : prev)
|
||||
setEditMode(false)
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
@@ -188,7 +187,12 @@ export default function NoteScreen() {
|
||||
|
||||
const handleShare = async () => {
|
||||
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 = () => {
|
||||
@@ -257,20 +261,21 @@ export default function NoteScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mode édition */}
|
||||
{/* Mode édition — titre uniquement, contenu preserve HTML */}
|
||||
{note && editMode && (
|
||||
<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} />
|
||||
<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 */}
|
||||
<View style={s.toolbar}>
|
||||
<MicButton state={audioState} onPress={handleMic} />
|
||||
{audioState === 'recording'
|
||||
? <Text style={s.recordHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
||||
: <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>}
|
||||
<TouchableOpacity onPress={handleSave} disabled={saving} style={[s.aiBtn, { flex: 1, justifyContent: 'center' }]} activeOpacity={0.8}>
|
||||
<Text style={s.aiBtnText}>{saving ? 'Sauvegarde…' : 'Enregistrer le titre'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function TitleSheet({ visible, onClose, content, onSelect }: Props) {
|
||||
if (visible && content.trim()) {
|
||||
fetchTitles()
|
||||
}
|
||||
}, [visible])
|
||||
}, [visible, content])
|
||||
|
||||
const fetchTitles = async () => {
|
||||
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 () => {
|
||||
try { await apiFetch(ENDPOINTS.logout, { method: 'POST' }) } catch {}
|
||||
await clearToken()
|
||||
set({ user: null })
|
||||
},
|
||||
|
||||
@@ -6,7 +6,13 @@ export const C = {
|
||||
concrete: '#8A8A82',
|
||||
border: '#E8E6E0',
|
||||
white: '#FFFFFF',
|
||||
surface: '#F5F4F0',
|
||||
rose: '#e11d48',
|
||||
roseBg: '#fff1f2',
|
||||
roseBorder: '#fecdd3',
|
||||
emerald: '#10b981',
|
||||
emeraldBg: '#ecfdf5',
|
||||
blue: '#3b82f6',
|
||||
amber: '#f59e0b',
|
||||
purple: '#8b5cf6',
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
||||
|
||||
const NetworkGraph = dynamic(
|
||||
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
||||
@@ -99,6 +100,7 @@ export default function InsightsPage() {
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string>('')
|
||||
const peek = useNotePeek()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -258,9 +260,11 @@ export default function InsightsPage() {
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string) => {
|
||||
router.push(`/home?openNote=${noteId}`)
|
||||
peek.open(noteId)
|
||||
}
|
||||
|
||||
const isRtl = locale === 'fa' || locale === 'ar'
|
||||
|
||||
const motionConfig = prefersReducedMotion
|
||||
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
|
||||
: {}
|
||||
@@ -274,9 +278,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="flex items-center gap-4">
|
||||
<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"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label={t('sidebar.openNavigation') || 'Open navigation'}
|
||||
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('toggle-insights-sidebar'))}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
@@ -868,6 +872,16 @@ export default function InsightsPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,11 +37,14 @@ export default async function MainLayout({
|
||||
>
|
||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
||||
<Suspense fallback={<div className="hidden w-80 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} />
|
||||
</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}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Note, Notebook } from '@/lib/types'
|
||||
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
|
||||
import { restoreNotebook, permanentDeleteNotebook } from '@/context/notebooks-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/settings/appearance', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('ai-settings')
|
||||
revalidateTag('ai-settings', 'default')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -472,7 +472,6 @@ export async function createNote(data: {
|
||||
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||
|
||||
// console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
// Detect user's language from their existing notes for localized prompts
|
||||
@@ -497,7 +496,6 @@ export async function createNote(data: {
|
||||
userLang
|
||||
)
|
||||
|
||||
// console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
||||
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
@@ -531,7 +529,6 @@ export async function createNote(data: {
|
||||
console.error('[BG] Auto-labeling failed:', error)
|
||||
}
|
||||
} else {
|
||||
// console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
||||
}
|
||||
})().catch(e => console.error('[BG] Uncaught background error:', e))
|
||||
|
||||
@@ -632,14 +629,12 @@ export async function updateNote(id: string, data: {
|
||||
updateData.contentUpdatedAt = new Date()
|
||||
}
|
||||
|
||||
// console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
|
||||
let note
|
||||
try {
|
||||
note = await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: updateData
|
||||
})
|
||||
// console.log('[updateNote] Succeeded, note id:', note?.id)
|
||||
} catch (dbError: any) {
|
||||
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
||||
throw dbError
|
||||
@@ -693,7 +688,6 @@ export async function updateNote(id: string, data: {
|
||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||
const isStructuralChange = structuralFields.some(field => field in data)
|
||||
|
||||
// console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
||||
|
||||
if (!options?.skipRevalidation) {
|
||||
try { revalidatePath(`/note/${id}`) } catch {}
|
||||
@@ -952,17 +946,25 @@ export async function syncAllEmbeddings() {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { is: null }
|
||||
}
|
||||
},
|
||||
take: 200,
|
||||
})
|
||||
for (const note of notesToSync) {
|
||||
if (!note.content) continue;
|
||||
try {
|
||||
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||
if (embedding) {
|
||||
await upsertNoteEmbedding(note.id, embedding)
|
||||
updatedCount++;
|
||||
|
||||
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 {
|
||||
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||
if (embedding) {
|
||||
await upsertNoteEmbedding(note.id, embedding)
|
||||
updatedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[syncEmbeddings] Failed for note ${note.id}:`, e)
|
||||
}
|
||||
} catch (e) { }
|
||||
}));
|
||||
}
|
||||
return { success: true, count: updatedCount }
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -205,11 +205,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
||||
})
|
||||
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 moved = 0
|
||||
|
||||
@@ -254,7 +249,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
console.log(`[organize-notebook] Created sub-notebook "${newSub.name}" (id: ${newSub.id}) under ${plan.notebookId}`)
|
||||
targetNotebookId = newSub.id
|
||||
created++
|
||||
}
|
||||
@@ -269,7 +263,6 @@ export async function executeNotebookOrganization(plan: OrganizationPlan): Promi
|
||||
},
|
||||
data: { notebookId: targetNotebookId },
|
||||
})
|
||||
console.log(`[organize-notebook] Moved ${result.count} notes to "${group.name}"`)
|
||||
moved += result.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
|
||||
|
||||
@@ -42,13 +42,8 @@ export async function POST(request: NextRequest) {
|
||||
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 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)
|
||||
const seenNoteIds = new Set<string>()
|
||||
|
||||
@@ -28,10 +28,10 @@ interface SuggestChartsResponse {
|
||||
analyzedText: string
|
||||
detectedData: string
|
||||
hasData: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
console.log('[suggest-charts] ===== REQUEST START =====')
|
||||
|
||||
// 1. Auth check
|
||||
const session = await auth()
|
||||
@@ -47,16 +47,13 @@ export async function POST(req: Request) {
|
||||
})
|
||||
}
|
||||
const userId = session.user.id
|
||||
console.log('[suggest-charts] userId:', userId)
|
||||
|
||||
// 1.5 Quota check
|
||||
try {
|
||||
const sysConfigEarly = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
|
||||
console.log('[suggest-charts] BYOK:', willUseByok)
|
||||
if (!willUseByok) {
|
||||
await reserveUsageOrThrow(userId, 'suggest_charts')
|
||||
console.log('[suggest-charts] Quota OK')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[suggest-charts] QUOTA ERROR:', err)
|
||||
@@ -84,7 +81,6 @@ export async function POST(req: Request) {
|
||||
} satisfies SuggestChartsResponse, { status: 400 })
|
||||
}
|
||||
const { content, selection, noteId } = body
|
||||
console.log('[suggest-charts] contentLen:', content?.length, 'selectionLen:', selection?.length)
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
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()
|
||||
console.log('[suggest-charts] analyzeLen:', textToAnalyze.length, 'preview:', textToAnalyze.substring(0, 100))
|
||||
|
||||
// 3. Build AI context
|
||||
let provider, model
|
||||
@@ -106,7 +101,6 @@ export async function POST(req: Request) {
|
||||
const sysConfig = await getSystemConfig()
|
||||
provider = getChatProvider(sysConfig)
|
||||
model = provider.getModel()
|
||||
console.log('[suggest-charts] AI model:', model)
|
||||
} catch (e) {
|
||||
console.error('[suggest-charts] AI ROUTE ERROR:', e)
|
||||
return Response.json({
|
||||
@@ -154,7 +148,6 @@ Response format (COPY this structure):
|
||||
})
|
||||
|
||||
// 5. Parse AI response - be very lenient
|
||||
console.log('[suggest-charts] AI response:', text.substring(0, 500))
|
||||
|
||||
let parsed: SuggestChartsResponse
|
||||
try {
|
||||
@@ -227,7 +220,7 @@ Response format (COPY this structure):
|
||||
if (baseSuggestion) {
|
||||
const types = ['bar', 'line', 'pie'].filter(t => t !== baseSuggestion.type)
|
||||
while (parsed.suggestions.length < 3 && types.length > 0) {
|
||||
parsed.suggestions.push({ ...baseSuggestion, type: types.shift()! })
|
||||
parsed.suggestions.push({ ...baseSuggestion, type: types.shift() as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
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) {
|
||||
try {
|
||||
console.log('[/api/ai/tags] contextual request — notebookId:', notebookId, 'content length:', content.length)
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
@@ -64,7 +64,6 @@ export async function POST(req: NextRequest) {
|
||||
language
|
||||
);
|
||||
|
||||
console.log('[/api/ai/tags] suggestions:', suggestions.length, suggestions.map(s => `${s.label}(${s.confidence})`))
|
||||
|
||||
const convertedTags = suggestions.map(s => ({
|
||||
tag: s.label,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emitToSession } from '@/lib/socket-emit'
|
||||
|
||||
const convertSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
notebookId: z.string().min(1).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
@@ -21,7 +22,7 @@ export async function POST(
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId } = convertSchema.parse(body)
|
||||
const { ideaId, notebookId: paramNotebookId } = convertSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
@@ -51,7 +52,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
let sourceSection = ''
|
||||
let targetNotebookId: string | null = null
|
||||
let targetNotebookId: string | null = paramNotebookId ?? null
|
||||
|
||||
if (brainstormSession.exportedNoteId) {
|
||||
const exportedNote = await prisma.note.findUnique({
|
||||
@@ -153,7 +154,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 })
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ export async function POST(
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -75,7 +75,6 @@ async function buildPptx(spec: any): Promise<Buffer> {
|
||||
}
|
||||
|
||||
case 'bullets': {
|
||||
console.log('[PPTX] Rendering bullets slide:', slide.title, 'items:', slide.items?.length)
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
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' },
|
||||
})
|
||||
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
|
||||
items.forEach((item: string, i: number) => {
|
||||
s.addText(item, {
|
||||
|
||||
@@ -4,10 +4,8 @@ import { prisma } from '@/lib/prisma'
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const canvasId = req.nextUrl.searchParams.get('id')
|
||||
console.log('[Slides API] Request for id:', canvasId)
|
||||
|
||||
if (!canvasId) {
|
||||
console.log('[Slides API] ERROR: Missing id')
|
||||
return new Response(JSON.stringify({ error: 'Missing id' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -19,39 +17,31 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
console.log('[Slides API] ERROR: Canvas not found for id:', canvasId)
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
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
|
||||
try {
|
||||
parsed = JSON.parse(canvas.data)
|
||||
} catch (parseErr) {
|
||||
console.log('[Slides API] ERROR: JSON parse failed:', parseErr)
|
||||
return new Response(JSON.stringify({ error: 'Invalid data' }), {
|
||||
status: 500,
|
||||
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) {
|
||||
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' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[Slides API] SUCCESS: Returning HTML, length:', parsed.html.length)
|
||||
|
||||
return new Response(parsed.html, {
|
||||
status: 200,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
@@ -53,6 +54,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
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
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||
@@ -153,14 +162,14 @@ Fais un résumé concis (max 200 mots) de cette conversation. Garde les informat
|
||||
})
|
||||
|
||||
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),
|
||||
]
|
||||
] as any
|
||||
} else if (conversation.summary) {
|
||||
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),
|
||||
]
|
||||
] as any
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat] Summary generation failed, using full context:', e)
|
||||
|
||||
@@ -47,14 +47,12 @@ export async function POST(request: NextRequest) {
|
||||
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 }[] = []
|
||||
|
||||
// Execute agents sequentially (max 3 per cycle)
|
||||
for (const agent of dueAgents.slice(0, 3)) {
|
||||
try {
|
||||
console.log(`[CronAgents] Executing agent ${agent.id} (${agent.frequency})`)
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(agent.id, agent.userId)
|
||||
|
||||
@@ -66,7 +64,6 @@ export async function POST(request: NextRequest) {
|
||||
timezone: agent.timezone,
|
||||
})
|
||||
|
||||
console.log(`[CronAgents] Agent ${agent.id} done. success=${result.success}, nextRun=${nextRun?.toISOString() ?? 'null'}`)
|
||||
|
||||
await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
|
||||
@@ -56,6 +56,8 @@ export async function GET(request: NextRequest) {
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
@@ -1,17 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { redis } from '@/lib/redis'
|
||||
|
||||
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')
|
||||
if (!url) {
|
||||
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 {
|
||||
const parsed = new URL(url)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
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 timeout = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
@@ -33,6 +80,8 @@ export async function GET(req: NextRequest) {
|
||||
const html = await res.text()
|
||||
const data = extractMetadata(html, parsed)
|
||||
|
||||
try { await redis.setex(cacheKey, 300, JSON.stringify(data)) } catch {}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': 'public, s-maxage=86400' },
|
||||
})
|
||||
|
||||
@@ -7,12 +7,13 @@ export const dynamic = 'force-dynamic'
|
||||
export async function GET(req: Request) {
|
||||
// Secure endpoint with bearer token (METRICS_TOKEN env var)
|
||||
const metricsToken = process.env.METRICS_TOKEN
|
||||
if (metricsToken) {
|
||||
const authHeader = req.headers.get('authorization') ?? ''
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
||||
if (token !== metricsToken) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
if (!metricsToken) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
const authHeader = req.headers.get('authorization') ?? ''
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
||||
if (token !== metricsToken) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -11,7 +11,7 @@ const MODE_MAP: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_gra
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
const { text, mode = 'improve' } = await req.json().catch(() => ({}))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
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 })
|
||||
|
||||
const { content } = await req.json().catch(() => ({}))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
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 })
|
||||
|
||||
const formData = await req.formData().catch(() => null)
|
||||
|
||||
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'
|
||||
|
||||
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 })
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const now = new Date()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
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 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
import { computeSm2Update } from '@/lib/flashcards/sm2'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
|
||||
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 })
|
||||
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
@@ -33,7 +33,7 @@ export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
@@ -61,7 +61,7 @@ export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const userId = getMobileUserId(req)
|
||||
const userId = await getMobileUserId(req)
|
||||
if (!userId) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -19,7 +19,7 @@ function getTodayContent(title: string): string {
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
const todayKey = getTodayKey()
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
|
||||
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 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
@@ -43,7 +43,7 @@ export async function GET(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 })
|
||||
|
||||
const { title, content, notebookId } = await req.json().catch(() => ({}))
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
|
||||
import { getMobileUserId } from '@/lib/mobile-auth'
|
||||
|
||||
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 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function PATCH(
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
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) {
|
||||
const descendantIds = await getDescendantIds(id)
|
||||
|
||||
@@ -66,13 +66,13 @@ export async function GET(
|
||||
console.error('[network] NoteLink query failed:', err)
|
||||
}
|
||||
|
||||
const mapBacklink = (bl: (typeof backlinks)[number]) => ({
|
||||
const mapBacklink = (bl: any) => ({
|
||||
id: bl.id,
|
||||
note: bl.sourceNote,
|
||||
contextSnippet: bl.contextSnippet,
|
||||
createdAt: bl.createdAt,
|
||||
})
|
||||
const mapOutbound = (ol: (typeof outbound)[number]) => ({
|
||||
const mapOutbound = (ol: any) => ({
|
||||
id: ol.id,
|
||||
note: ol.targetNote,
|
||||
contextSnippet: ol.contextSnippet,
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function POST(req: NextRequest) {
|
||||
links: n.links ?? undefined,
|
||||
trashedAt: parseDate(n.trashedAt),
|
||||
notebookId: n.notebookId ?? null,
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||
updatedAt: new Date(),
|
||||
labelRelations: {
|
||||
set: validLabelIds.map(id => ({ id })),
|
||||
@@ -187,7 +187,7 @@ export async function POST(req: NextRequest) {
|
||||
userId,
|
||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||
labelRelations: {
|
||||
connect: validLabelIds.map(id => ({ id })),
|
||||
},
|
||||
@@ -211,7 +211,7 @@ export async function POST(req: NextRequest) {
|
||||
userId,
|
||||
createdAt: parseDate(n.createdAt) ?? new Date(),
|
||||
updatedAt: parseDate(n.updatedAt) ?? new Date(),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt),
|
||||
contentUpdatedAt: parseDate(n.contentUpdatedAt) ?? undefined,
|
||||
},
|
||||
})
|
||||
stats.notes++
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
||||
// Store as a notification to the note owner + admin
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: note.userId,
|
||||
userId: note.userId!,
|
||||
type: 'content_report',
|
||||
title: `Signalement : ${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 (color !== undefined) updateData.color = color
|
||||
if (type !== undefined) updateData.type = type
|
||||
if (checkItems !== undefined) updateData.checkItems = checkItems ?? null
|
||||
if (labels !== undefined) updateData.labels = labels ?? null
|
||||
if (checkItems !== undefined) updateData.checkItems = checkItems != null ? JSON.stringify(checkItems) : null
|
||||
if (labels !== undefined) updateData.labels = labels != null ? JSON.stringify(labels) : null
|
||||
if (isPinned !== undefined) updateData.isPinned = isPinned
|
||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||
if (images !== undefined) updateData.images = images ?? null
|
||||
|
||||
@@ -45,7 +45,6 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
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)
|
||||
// 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) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
title: b.seedIdea,
|
||||
createdAt: b.createdAt,
|
||||
ideasCount: b.ideas.length,
|
||||
})),
|
||||
@@ -196,9 +196,9 @@ export async function GET() {
|
||||
|
||||
const canvasFolder = zip.folder('canvases')!
|
||||
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 += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n`
|
||||
canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n`
|
||||
@@ -232,7 +232,7 @@ export async function GET() {
|
||||
}))
|
||||
|
||||
const offlineCanvases = brainstorms.map((b) => ({
|
||||
title: b.title || 'Untitled Brainstorm',
|
||||
title: b.seedIdea || 'Untitled Brainstorm',
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
ideas: b.ideas.map((i) => ({
|
||||
title: i.title,
|
||||
@@ -499,7 +499,7 @@ export async function GET() {
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
const dateString = new Date().toISOString().split('T')[0]
|
||||
return new NextResponse(zipBuffer, {
|
||||
return new NextResponse(new Uint8Array(zipBuffer) as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`,
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
|
||||
@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) */
|
||||
@theme {
|
||||
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
||||
|
||||
@@ -97,7 +97,7 @@ export default async function RootLayout({
|
||||
data-server-accent={serverAccent}
|
||||
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="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
|
||||
<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} />
|
||||
{children}
|
||||
<LanguageProvider initialLanguage="en">
|
||||
<CookieConsentRoot initialAnonymousAnalytics={aiSettings?.anonymousAnalytics} />
|
||||
<CookieConsentRoot />
|
||||
</LanguageProvider>
|
||||
<Toaster />
|
||||
</SessionProviderWrapper>
|
||||
|
||||
@@ -40,7 +40,7 @@ function getActionLabel(action: string, t: (key: string) => string | undefined):
|
||||
function timeAgo(dateStr: string, t: (key: string) => string | undefined): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
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`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
|
||||
181
memento-note/components/brainstorm/brainstorm-confirm-dialog.tsx
Normal file
181
memento-note/components/brainstorm/brainstorm-confirm-dialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useState, memo } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Wind, FolderOpen, X, Loader2, ChevronDown } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
interface BrainstormConfirmDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (notebookId: string | null) => void
|
||||
note: Note | null
|
||||
}
|
||||
|
||||
function BrainstormConfirmDialogInner({ open, onClose, onConfirm, note }: BrainstormConfirmDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const { notebooks } = useNotebooks()
|
||||
const [showNotebookSelect, setShowNotebookSelect] = useState(false)
|
||||
|
||||
const noteNotebook = notebooks.find(nb => nb.id === note?.notebookId)
|
||||
const defaultNotebookName = noteNotebook?.name || t('brainstorm.defaultNotebook') || 'Brainstorm'
|
||||
const defaultNotebookId = note?.notebookId ?? null
|
||||
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(defaultNotebookId)
|
||||
const [selectedNotebookName, setSelectedNotebookName] = useState(defaultNotebookName)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = () => {
|
||||
setLoading(true)
|
||||
onConfirm(selectedNotebookId)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setLoading(false)
|
||||
setSelectedNotebookId(defaultNotebookId)
|
||||
setSelectedNotebookName(defaultNotebookName)
|
||||
setShowNotebookSelect(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && note && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.92, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.92, y: 20 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 30 }}
|
||||
className="bg-memento-paper dark:bg-zinc-900 rounded-3xl shadow-2xl border border-border/40 w-full max-w-md overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center">
|
||||
<Wind size={16} className="text-brand-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-memento-serif text-base font-medium text-ink dark:text-dark-ink">
|
||||
{t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold">
|
||||
{t('brainstorm.confirmSubtitle') || 'AI will expand your idea'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
|
||||
aria-label={t('common.close') || 'Close'}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{/* Seed preview */}
|
||||
<div className="p-4 rounded-2xl bg-white/60 dark:bg-white/5 border border-border/30">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
|
||||
{t('brainstorm.seedLabel') || 'Seed idea'}
|
||||
</p>
|
||||
<p className="text-sm font-memento-serif text-ink dark:text-dark-ink leading-relaxed line-clamp-3">
|
||||
{note.title || t('notes.untitled')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Destination notebook */}
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
|
||||
{t('brainstorm.destinationLabel') || 'Notes will be saved to'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNotebookSelect(!showNotebookSelect)}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-white/60 dark:bg-white/5 border border-border/30 hover:border-brand-accent/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
|
||||
<FolderOpen size={13} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink dark:text-dark-ink truncate">
|
||||
{selectedNotebookName}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className={`text-concrete transition-transform ${showNotebookSelect ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showNotebookSelect && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-1 max-h-48 overflow-y-auto rounded-xl border border-border/30 bg-white dark:bg-zinc-800 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setSelectedNotebookId(null); setSelectedNotebookName(t('brainstorm.noNotebook') || 'No notebook'); setShowNotebookSelect(false) }}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm text-concrete cursor-pointer"
|
||||
>
|
||||
{t('brainstorm.noNotebook') || 'No notebook'}
|
||||
</button>
|
||||
{notebooks.map(nb => (
|
||||
<button
|
||||
key={nb.id}
|
||||
onClick={() => { setSelectedNotebookId(nb.id); setSelectedNotebookName(nb.name); setShowNotebookSelect(false) }}
|
||||
className={`w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm cursor-pointer flex items-center gap-2 ${selectedNotebookId === nb.id ? 'text-brand-accent font-medium' : 'text-ink dark:text-dark-ink'}`}
|
||||
>
|
||||
<FolderOpen size={12} className="shrink-0 opacity-50" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border/20 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-medium text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className="px-5 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-bold uppercase tracking-wider hover:bg-brand-accent/90 transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<><Loader2 size={14} className="animate-spin" /> {t('brainstorm.launching') || 'Launching…'}</>
|
||||
) : (
|
||||
<><Wind size={14} /> {t('brainstorm.launch') || 'Brainstorm'}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrainstormConfirmDialog = memo(BrainstormConfirmDialogInner)
|
||||
@@ -1,4 +1,6 @@
|
||||
'use client'
|
||||
import { toast } from 'sonner'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -26,9 +28,11 @@ import {
|
||||
LayoutGrid,
|
||||
X,
|
||||
Edit2,
|
||||
Maximize2,
|
||||
} from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
||||
import {
|
||||
useBrainstormSession,
|
||||
useBrainstormSessions,
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
|
||||
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
|
||||
import { ActivityFeed } from '@/components/brainstorm/activity-feed'
|
||||
import { ConvertNotebookDialog } from './convert-notebook-dialog'
|
||||
import { BrainstormShareDialog } from '@/components/brainstorm/brainstorm-share-dialog'
|
||||
import { GhostCursor } from '@/components/brainstorm/ghost-cursor'
|
||||
import { PlaybackBar } from '@/components/brainstorm/playback-bar'
|
||||
@@ -76,12 +81,15 @@ const WAVE_COLORS: Record<number, { border: string; bg: string; text: string }>
|
||||
|
||||
export function BrainstormPage() {
|
||||
const router = useRouter()
|
||||
const peek = useNotePeek()
|
||||
const { notebooks } = useNotebooks()
|
||||
const searchParams = useSearchParams()
|
||||
const { t, language } = useLanguage()
|
||||
const { data: authSession } = useSession()
|
||||
const urlSessionId = searchParams.get('session')
|
||||
const urlSeed = searchParams.get('seed')
|
||||
const urlSourceNoteId = searchParams.get('sourceNoteId')
|
||||
const urlNotebookId = searchParams.get('notebookId')
|
||||
const urlInviteToken = searchParams.get('invite')
|
||||
|
||||
const [seedInput, setSeedInput] = useState('')
|
||||
@@ -102,8 +110,9 @@ export function BrainstormPage() {
|
||||
|
||||
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
|
||||
|
||||
// Auto-sélectionner la dernière session si aucune session active
|
||||
// Auto-sélectionner la dernière session si aucune session active ET pas de seed dans l'URL
|
||||
useEffect(() => {
|
||||
if (urlSeed || urlInviteToken) return // Ne pas auto-select si on arrive avec un seed
|
||||
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
|
||||
const last = sessions[0]
|
||||
setActiveSessionId(last.id)
|
||||
@@ -121,6 +130,7 @@ export function BrainstormPage() {
|
||||
const expandIdea = useExpandIdea(activeSessionId || '')
|
||||
const dismissIdea = useDismissIdea(activeSessionId || '')
|
||||
const convertIdea = useConvertIdea(activeSessionId || '')
|
||||
const [convertConfirm, setConvertConfirm] = useState<BrainstormIdea | null>(null)
|
||||
const exportBrainstorm = useExportBrainstorm(activeSessionId || '')
|
||||
const finalizeBrainstorm = useFinalizeBrainstorm(activeSessionId || '')
|
||||
const deleteBrainstorm = useDeleteBrainstorm()
|
||||
@@ -132,8 +142,7 @@ export function BrainstormPage() {
|
||||
const renameSession = useRenameBrainstormSession(activeSessionId || '')
|
||||
const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
|
||||
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: string } | null>(null)
|
||||
|
||||
const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
|
||||
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
|
||||
@@ -238,15 +247,27 @@ export function BrainstormPage() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleConvert = async (idea: BrainstormIdea) => {
|
||||
const handleConvert = async (idea: BrainstormIdea, targetNotebookId: string | null = urlNotebookId) => {
|
||||
try {
|
||||
const result = await convertIdea.mutateAsync(idea.id)
|
||||
const result = await convertIdea.mutateAsync({ ideaId: idea.id, notebookId: targetNotebookId })
|
||||
if (result?.id) {
|
||||
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id })
|
||||
setTimeout(() => {
|
||||
setConvertToast(null)
|
||||
router.push(`/home?openNote=${result.id}`)
|
||||
}, 2000)
|
||||
let nbName = 'carnet'
|
||||
if (result.notebookId) {
|
||||
const nb = notebooks.find(n => n.id === result.notebookId)
|
||||
nbName = nb?.name || 'carnet'
|
||||
}
|
||||
toast.success(t('brainstorm.noteCreatedIn') || 'Note created', {
|
||||
description: `${result.title || idea.title} → 📁 ${nbName}`,
|
||||
action: {
|
||||
label: t('brainstorm.openNotebook') || 'Open notebook',
|
||||
onClick: () => {
|
||||
if (result.notebookId) router.push(`/home?notebook=${result.notebookId}`)
|
||||
else router.push('/home')
|
||||
},
|
||||
},
|
||||
duration: 15000,
|
||||
})
|
||||
peek.open(result.id)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -287,11 +308,15 @@ export function BrainstormPage() {
|
||||
const result = await exportBrainstorm.mutateAsync()
|
||||
if (result?.id) {
|
||||
const notebookName = result._notebookName || t('brainstorm.exportDefaultNotebookName')
|
||||
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName })
|
||||
setTimeout(() => {
|
||||
setExportToast(null)
|
||||
router.push(`/home?openNote=${result.id}`)
|
||||
}, 2000)
|
||||
toast.success(t('brainstorm.noteCreated') || 'Note created', {
|
||||
description: `${result.title || t('brainstorm.exportDefaultNoteTitle')} — ${notebookName}`,
|
||||
action: {
|
||||
label: t('notePeek.openFully') || 'Open',
|
||||
onClick: () => router.push(`/home?openNote=${result.id}`),
|
||||
},
|
||||
duration: 10000,
|
||||
})
|
||||
peek.open(result.id)
|
||||
return
|
||||
}
|
||||
const impact = await finalizeBrainstorm.mutateAsync()
|
||||
@@ -846,7 +871,7 @@ export function BrainstormPage() {
|
||||
</div>
|
||||
{ref.noteId && (
|
||||
<button
|
||||
onClick={() => router.push(`/home?openNote=${ref.noteId}`)}
|
||||
onClick={() => ref.noteId && peek.open(ref.noteId)}
|
||||
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
|
||||
>
|
||||
{t('brainstorm.viewNote') || 'View'}
|
||||
@@ -873,7 +898,7 @@ export function BrainstormPage() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConvert(selectedIdea)}
|
||||
onClick={() => setConvertConfirm(selectedIdea)}
|
||||
disabled={selectedIdea.status === 'converted' || convertIdea.isPending}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-emerald-400/40 hover:bg-emerald-500/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
@@ -1124,49 +1149,29 @@ export function BrainstormPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{exportToast && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
|
||||
>
|
||||
<Wind size={18} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold">{exportToast.noteTitle}</span>
|
||||
<span className="text-[11px] text-emerald-100">
|
||||
{t('brainstorm.exportNotebookPrefix')} {exportToast.notebookName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
|
||||
{t('brainstorm.exportOpening')}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Peek panel — drawer latéral pour aperçu note sans quitter le brainstorm */}
|
||||
<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() }}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{convertToast && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
|
||||
>
|
||||
<FileText size={18} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold">{convertToast.noteTitle}</span>
|
||||
<span className="text-[11px] text-emerald-100">{t('brainstorm.noteCreated') || 'Note Created'}</span>
|
||||
</div>
|
||||
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
|
||||
{t('brainstorm.exportOpening')}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Dialog de sélection de carnet pour la conversion */}
|
||||
<ConvertNotebookDialog
|
||||
open={convertConfirm !== null}
|
||||
idea={convertConfirm}
|
||||
notebooks={notebooks}
|
||||
defaultNotebookId={urlNotebookId}
|
||||
onClose={() => setConvertConfirm(null)}
|
||||
onConfirm={(notebookId) => {
|
||||
if (!convertConfirm) return
|
||||
handleConvert(convertConfirm, notebookId)
|
||||
setConvertConfirm(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Tag as TagIcon, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -123,8 +124,10 @@ interface ContextualAIChatProps {
|
||||
noteContent?: string
|
||||
noteImages?: string[]
|
||||
noteId?: string
|
||||
/** Called when an action result should be injected into the note */
|
||||
onApplyToNote?: (newContent: string) => void
|
||||
/** Called when an action result should be injected into the note.
|
||||
* `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 */
|
||||
onUndoLastAction?: () => void
|
||||
/** Whether the last action has been applied (so we can show undo) */
|
||||
@@ -213,7 +216,7 @@ export function ContextualAIChat({
|
||||
|
||||
// Action state
|
||||
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 [translateTarget, setTranslateTarget] = useState('')
|
||||
|
||||
@@ -377,7 +380,7 @@ export function ContextualAIChat({
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
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) {
|
||||
mToast.error(e.message || t('ai.actionError'))
|
||||
} finally {
|
||||
@@ -387,7 +390,7 @@ export function ContextualAIChat({
|
||||
|
||||
const handleApplyPreview = () => {
|
||||
if (!actionPreview || !onApplyToNote) return
|
||||
onApplyToNote(actionPreview.text)
|
||||
onApplyToNote(actionPreview.text, { asRichText: actionPreview.asRichText })
|
||||
setActionPreview(null)
|
||||
mToast.success(t('ai.appliedToNote'))
|
||||
}
|
||||
@@ -712,7 +715,15 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
<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">
|
||||
<MarkdownContent content={actionPreview.text} />
|
||||
{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} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||
@@ -957,7 +968,7 @@ export function ContextualAIChat({
|
||||
</motion.button>
|
||||
)}
|
||||
<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 isActive = action.id === 'translate' && showLangPicker
|
||||
const Icon = action.icon
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -611,6 +611,25 @@ export function HomeClient({
|
||||
})
|
||||
}, [patchNoteInList])
|
||||
|
||||
// Écoute les mutations externes (versioning activé depuis éditeur, etc.)
|
||||
useEffect(() => {
|
||||
const onNoteChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<NoteChangeEvent>).detail
|
||||
if (!detail) return
|
||||
if (detail.type === 'updated') {
|
||||
patchNoteInList(detail.note.id, detail.note)
|
||||
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
|
||||
} else if (detail.type === 'deleted') {
|
||||
removeNoteFromList(detail.noteId)
|
||||
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
|
||||
} else if (detail.type === 'created' && detail.note) {
|
||||
setNotes(prev => [detail.note, ...prev])
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
}, [patchNoteInList])
|
||||
|
||||
const handleGridReorder = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
setSortOrder('manual')
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Box
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -44,7 +45,7 @@ export function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-8 py-4 flex items-center justify-between">
|
||||
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-4 sm:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
@@ -104,7 +105,7 @@ export function LandingPage() {
|
||||
{/* App Preview Mockup */}
|
||||
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
|
||||
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
|
||||
<img src="/images/workspace-hero.jpg" alt="Memento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<Image src="/images/workspace-hero.jpg" alt="Memento Workspace" width={1200} height={750} className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" priority />
|
||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
||||
|
||||
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
|
||||
|
||||
@@ -86,7 +86,7 @@ export function LoginForm({
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -98,6 +98,7 @@ export function LoginForm({
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -106,7 +107,7 @@ export function LoginForm({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center px-4">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)]">
|
||||
<label htmlFor="password" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)]">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<Link
|
||||
@@ -125,6 +126,7 @@ export function LoginForm({
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function MoveToNotebookPicker({
|
||||
close()
|
||||
}
|
||||
|
||||
const child = React.cloneElement(children, {
|
||||
const child = React.cloneElement(children as any, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
const childRef = (children as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
|
||||
@@ -71,7 +71,7 @@ export function MoveToNotebookPicker({
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
children.props.onClick?.(e)
|
||||
;(children as any).props.onClick?.(e)
|
||||
setOpen((prev) => !prev)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -362,22 +362,27 @@ export function NetworkGraph({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
{/* Pastilles de cluster — cliquables pour activer le focus */}
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%]">
|
||||
{clusters.map(c => {
|
||||
{/* Pastilles de cluster — triées par taille, avec compteurs */}
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-1.5 max-w-[85%]">
|
||||
{[...clusters]
|
||||
.sort((a, b) => b.noteIds.length - a.noteIds.length)
|
||||
.map(c => {
|
||||
const isSelected = String(c.id) === selectedClusterId
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider ${
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
|
||||
isSelected
|
||||
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
|
||||
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
|
||||
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span>{c.name ?? String(c.id)}</span>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
|
||||
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
|
||||
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
|
||||
{c.noteIds.length}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -127,7 +127,7 @@ export function NoteActions({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 w-8 p-0", currentReminder && "text-primary")}
|
||||
className={cn("h-9 w-9 p-0", currentReminder && "text-primary")}
|
||||
title={t('reminder.setReminder')}
|
||||
onClick={() => setShowReminder(true)}
|
||||
>
|
||||
@@ -154,7 +154,7 @@ export function NoteActions({
|
||||
{/* Color Palette */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 p-0" title={t('notes.changeColor')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -180,10 +180,10 @@ export function NoteActions({
|
||||
const iconMap: Record<string, React.ElementType> = { text: AlignLeft, markdown: FileCode2, richtext: PenLine, checklist: ListChecks }
|
||||
const TypeIcon = iconMap[noteType] || FileText
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 gap-1 px-2 text-xs", noteType !== 'text' && "text-primary bg-primary/10")}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 gap-1 px-2 text-xs", noteType !== 'text' && "text-primary bg-primary/10")}
|
||||
title={noteType === 'markdown' ? 'Markdown' : noteType === 'richtext' ? 'Rich Text' : noteType === 'checklist' ? 'Checklist' : 'Text'}
|
||||
onClick={onToggleMarkdown}
|
||||
>
|
||||
@@ -193,10 +193,23 @@ export function NoteActions({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* History — visible directement, pas caché dans le menu */}
|
||||
{onOpenHistory && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-9 w-9 p-0", historyEnabled ? "text-emerald-500" : "text-muted-foreground/60")}
|
||||
title={historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")}
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('notes.moreOptions')}>
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 p-0" aria-label={t('notes.moreOptions')}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -96,6 +96,7 @@ const NOTE_TYPE_ICONS: Record<NoteType, LucideIcon> = {
|
||||
markdown: FileCode2,
|
||||
richtext: PenLine,
|
||||
checklist: ListChecks,
|
||||
daily: FileText,
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
@@ -185,6 +186,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
@@ -204,7 +206,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
try {
|
||||
await updateNote(noteId, { reminder }, { skipRevalidation: true })
|
||||
setReminderDate(reminder)
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: reminder?.toISOString() ?? null } })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: (reminder?.toISOString() ?? null) as any } })
|
||||
if (reminder) {
|
||||
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
|
||||
} else {
|
||||
@@ -408,14 +410,13 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from view
|
||||
} catch (error) {
|
||||
console.error('Failed to leave share:', error)
|
||||
}
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
setIsDeleting(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to leave share:', error)
|
||||
}
|
||||
setShowLeaveDialog(false)
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
@@ -597,7 +598,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
{/* Versioning indicator */}
|
||||
{optimisticNote.historyEnabled && (
|
||||
<div
|
||||
className="absolute top-3 end-3 z-10 flex items-center gap-0.5 rounded-full bg-emerald-500/10 dark:bg-emerald-500/20 px-1.5 py-0.5"
|
||||
className="absolute top-2.5 end-2.5 z-10 flex items-center gap-1 rounded-full bg-emerald-500/15 dark:bg-emerald-500/25 border border-emerald-500/20 px-2 py-0.5 backdrop-blur-sm"
|
||||
title={t('notes.historyEnabledTooltip') || 'Version history enabled'}
|
||||
>
|
||||
<History className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
||||
@@ -657,7 +658,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLeaveShare()
|
||||
setShowLeaveDialog(true)
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-3 w-3 me-1" />
|
||||
@@ -885,6 +886,24 @@ export const NoteCard = memo(function NoteCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Leave Share Confirmation Dialog */}
|
||||
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.leaveShare')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('notes.confirmLeaveShare')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleLeaveShare}>
|
||||
{t('notes.leaveShare')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -936,6 +936,12 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
handleRemoveLink,
|
||||
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
|
||||
convertToRichText: (html) => {
|
||||
setContentImmediate(html)
|
||||
setIsMarkdown(false)
|
||||
setShowMarkdownPreview(false)
|
||||
setIsDirty(true)
|
||||
},
|
||||
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||
setShowReminderDialog,
|
||||
|
||||
@@ -46,7 +46,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'!max-w-[min(95vw,1600px)] h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
|
||||
'!max-w-[min(97vw,1800px)] h-[92vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
|
||||
state.colorClasses
|
||||
)}
|
||||
>
|
||||
@@ -201,9 +201,13 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
noteContent={state.content}
|
||||
noteImages={state.allImages}
|
||||
noteId={note.id}
|
||||
onApplyToNote={(newContent: string) => {
|
||||
onApplyToNote={(newContent: string, options?: { asRichText?: boolean }) => {
|
||||
actions.setPreviousContentForCopilot(state.content)
|
||||
actions.setContent(newContent)
|
||||
if (options?.asRichText) {
|
||||
actions.convertToRichText(newContent)
|
||||
} else {
|
||||
actions.setContent(newContent)
|
||||
}
|
||||
}}
|
||||
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
|
||||
if (state.previousContentForCopilot !== null) {
|
||||
@@ -215,6 +219,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
notebooks={notebooks}
|
||||
notebookId={note.notebookId ?? undefined}
|
||||
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
|
||||
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -72,8 +72,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
|
||||
|
||||
{/* BODY — max-w-4xl, responsive px, py-16 */}
|
||||
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
|
||||
{/* BODY — max-w-5xl, responsive px, py-16 */}
|
||||
<div className="max-w-5xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
|
||||
|
||||
{/* Breadcrumb + Title block */}
|
||||
<div className="space-y-4">
|
||||
@@ -130,8 +130,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area — max-w-3xl for wider reading column */}
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
|
||||
{/* Content area — max-w-4xl for wider reading column */}
|
||||
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">
|
||||
{state.quotaExceededFeature === 'reformulate' && (
|
||||
<InlinePaywall
|
||||
feature="reformulate"
|
||||
@@ -175,10 +175,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
noteContent={state.content}
|
||||
noteImages={state.allImages}
|
||||
noteId={note.id}
|
||||
onApplyToNote={(nc: string) => {
|
||||
onApplyToNote={(nc: string, options?: { asRichText?: boolean }) => {
|
||||
actions.setPreviousContentForCopilot(state.content)
|
||||
actions.setContent(nc)
|
||||
if (state.isMarkdown) actions.setShowMarkdownPreview(true)
|
||||
if (options?.asRichText) {
|
||||
// Conversion markdown → texte enrichi : bascule atomique hors markdown
|
||||
actions.convertToRichText(nc)
|
||||
} else {
|
||||
actions.setContent(nc)
|
||||
if (state.isMarkdown) actions.setShowMarkdownPreview(true)
|
||||
}
|
||||
}}
|
||||
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
|
||||
lastActionApplied={state.previousContentForCopilot !== null}
|
||||
|
||||
@@ -490,16 +490,15 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
try {
|
||||
let html: string
|
||||
if (state.isMarkdown) {
|
||||
const { marked } = await import('marked')
|
||||
html = await marked(state.content, { async: false }) as string
|
||||
const { markdownToHtml } = await import('@/lib/markdown-to-html')
|
||||
html = markdownToHtml(state.content)
|
||||
} else {
|
||||
html = state.content
|
||||
.split(/\n{2,}/)
|
||||
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
actions.setContent(html)
|
||||
actions.setIsMarkdown(false)
|
||||
actions.convertToRichText(html)
|
||||
|
||||
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
|
||||
duration: 8000,
|
||||
@@ -531,7 +530,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||
<div className="max-w-5xl mx-auto w-full px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||
<button
|
||||
onClick={handleCloseWithSave}
|
||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||
@@ -669,7 +668,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
|
||||
@@ -84,6 +84,9 @@ export interface NoteEditorActions {
|
||||
|
||||
setShowMarkdownPreview: (show: boolean) => void
|
||||
setIsMarkdown: (markdown: boolean) => void
|
||||
/** Bascule atomiquement la note en texte enrichi : applique le HTML immédiatement
|
||||
* et sort du mode markdown (source unique de conversion markdown → rich text). */
|
||||
convertToRichText: (html: string) => void
|
||||
setColor: (color: NoteColor) => void
|
||||
setSize: (size: NoteSize) => void
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) as any
|
||||
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
|
||||
|
||||
3
memento-note/components/note-peek/index.ts
Normal file
3
memento-note/components/note-peek/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useNotePeek } from './use-note-peek'
|
||||
export { NotePeekPanel } from './note-peek-panel'
|
||||
export { NotePeekContent } from './note-peek-content'
|
||||
66
memento-note/components/note-peek/note-peek-content.tsx
Normal file
66
memento-note/components/note-peek/note-peek-content.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
interface NotePeekContentProps {
|
||||
note: Note
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function NotePeekContent({ note, className, children }: NotePeekContentProps) {
|
||||
const htmlRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!htmlRef.current || note.isMarkdown) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const katex = (await import('katex')).default
|
||||
if (cancelled || !htmlRef.current) return
|
||||
htmlRef.current.querySelectorAll('.math-equation-block[data-latex], .inline-math[data-latex]').forEach(el => {
|
||||
const latex = el.getAttribute('data-latex') || ''
|
||||
const isDisplay = el.classList.contains('math-equation-block')
|
||||
try { el.innerHTML = katex.renderToString(latex, { displayMode: isDisplay, throwOnError: false }) } catch {}
|
||||
})
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [note.id, note.content, note.isMarkdown])
|
||||
|
||||
if (children) return <>{children}</>
|
||||
|
||||
if (note.isMarkdown) {
|
||||
return <MarkdownContent content={note.content} className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={htmlRef}
|
||||
dir="auto"
|
||||
className={`editor-body prose prose-lg dark:prose-invert max-w-none leading-relaxed text-ink dark:text-dark-ink
|
||||
[&_h1]:text-2xl [&_h1]:font-serif [&_h1]:font-semibold [&_h1]:mt-8 [&_h1]:mb-4
|
||||
[&_h2]:text-xl [&_h2]:font-serif [&_h2]:font-semibold [&_h2]:mt-6 [&_h2]:mb-3
|
||||
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-5 [&_h3]:mb-2
|
||||
[&_p]:my-3 [&_p]:leading-relaxed
|
||||
[&_ul]:list-disc [&_ul]:ps-6 [&_ul]:my-3
|
||||
[&_ol]:list-decimal [&_ol]:ps-6 [&_ol]:my-3
|
||||
[&_li]:my-1
|
||||
[&_blockquote]:border-l-4 [&_blockquote]:border-brand-accent [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4
|
||||
[&_pre]:bg-zinc-100 [&_pre]:dark:bg-zinc-900 [&_pre]:p-4 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:my-4
|
||||
[&_code]:bg-zinc-100 [&_code]:dark:bg-zinc-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono
|
||||
[&_a]:text-blue-600 [&_a]:underline
|
||||
[&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4
|
||||
[&_table]:w-full [&_table]:my-4 [&_table]:border-collapse
|
||||
[&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-left
|
||||
[&_td]:border [&_td]:border-border [&_td]:p-2
|
||||
[&_hr]:border-border [&_hr]:my-6
|
||||
[&_[data-callout-type]]:p-4 [&_[data-callout-type]]:rounded-lg [&_[data-callout-type]]:my-4
|
||||
[&_div[data-type='toggle-block']]:my-3
|
||||
[&_div[data-type='toggle-block']_details]:rounded-lg [&_div[data-type='toggle-block']_details]:border [&_div[data-type='toggle-block']_details]:border-border ${className || ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(note.content) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
145
memento-note/components/note-peek/note-peek-panel.tsx
Normal file
145
memento-note/components/note-peek/note-peek-panel.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
||||
import { X, Maximize2, Loader2, FolderOpen } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { NotePeekContent } from './note-peek-content'
|
||||
|
||||
interface NotePeekPanelProps {
|
||||
note: Note | null
|
||||
blockId?: string
|
||||
loading?: boolean
|
||||
mode: 'overlay' | 'inline'
|
||||
onClose: () => void
|
||||
onOpenFully?: (note: Note) => void
|
||||
labelKey?: string
|
||||
renderContent?: (note: Note, blockId?: string) => ReactNode
|
||||
}
|
||||
|
||||
export function NotePeekPanel({
|
||||
note,
|
||||
blockId,
|
||||
loading = false,
|
||||
mode,
|
||||
onClose,
|
||||
onOpenFully,
|
||||
labelKey = 'notePeek.label',
|
||||
renderContent,
|
||||
}: NotePeekPanelProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const isOpen = note !== null || loading
|
||||
|
||||
const spring = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, stiffness: 340, damping: 34 }
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="shrink-0 px-5 py-3 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete truncate">
|
||||
{loading
|
||||
? (t('notePeek.loading') || 'Loading…')
|
||||
: (note?.title || t('notePeek.untitled') || t('insightsView.unknownNote'))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onOpenFully && note && (
|
||||
<button
|
||||
onClick={() => onOpenFully(note)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-accent/50 focus-visible:outline-none"
|
||||
title={t('notePeek.openFullyHelp') || 'Open full screen'}
|
||||
aria-label={t('notePeek.openFully') || 'Open fully'}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-accent/50 focus-visible:outline-none"
|
||||
aria-label={t('notePeek.close') || 'Close'}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-brand-accent/40" size={28} />
|
||||
</div>
|
||||
) : note ? (
|
||||
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-8 pb-24">
|
||||
{renderContent ? (
|
||||
renderContent(note, blockId)
|
||||
) : (
|
||||
<>
|
||||
{note.notebookId && (
|
||||
<a
|
||||
href={`/home?notebook=${note.notebookId}`}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-brand-accent hover:text-brand-accent/70 transition-colors mb-2 cursor-pointer"
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
{t('brainstorm.openNotebook') || 'Open notebook'}
|
||||
</a>
|
||||
)}
|
||||
<h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6">
|
||||
{note.title || t('notePeek.untitled') || 'Untitled'}
|
||||
</h2>
|
||||
<NotePeekContent note={note} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (mode === 'overlay') {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
key="note-peek-overlay"
|
||||
initial={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
transition={spring}
|
||||
className={`fixed top-0 ${isRtl ? 'left-0' : 'right-0'} z-[80] h-full w-full sm:w-[min(50vw,640px)] bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden shadow-2xl ${isRtl ? 'border-r' : 'border-l'} border-black/10 dark:border-white/10`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t(labelKey) || 'Note preview'}
|
||||
>
|
||||
{content}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// inline mode
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
key="note-peek-inline"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={spring}
|
||||
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
|
||||
isRtl
|
||||
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
}`}
|
||||
aria-label={t(labelKey) || 'Note preview'}
|
||||
>
|
||||
{content}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
60
memento-note/components/note-peek/use-note-peek.ts
Normal file
60
memento-note/components/note-peek/use-note-peek.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import type { Note } from '@/lib/types'
|
||||
import {
|
||||
NOTE_PEEK_OPEN_EVENT,
|
||||
NOTE_PEEK_CLOSE_EVENT,
|
||||
type NotePeekOpenDetail,
|
||||
} from '@/lib/note-peek-sync'
|
||||
|
||||
interface UseNotePeekOptions {
|
||||
selfNoteId?: string
|
||||
}
|
||||
|
||||
export function useNotePeek(opts: UseNotePeekOptions = {}) {
|
||||
const { selfNoteId } = opts
|
||||
const [note, setNote] = useState<Note | null>(null)
|
||||
const [blockId, setBlockId] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const open = useCallback(async (targetNoteId: string, targetBlockId?: string) => {
|
||||
if (selfNoteId && targetNoteId === selfNoteId) return
|
||||
setLoading(true)
|
||||
setIsOpen(true)
|
||||
setBlockId(targetBlockId)
|
||||
try {
|
||||
const fetched = await getNoteById(targetNoteId)
|
||||
if (fetched) setNote(fetched)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selfNoteId])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setNote(null)
|
||||
setBlockId(undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<NotePeekOpenDetail>).detail
|
||||
if (!detail) return
|
||||
void open(detail.noteId, detail.blockId)
|
||||
}
|
||||
const onClose = () => close()
|
||||
window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpen)
|
||||
window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClose)
|
||||
return () => {
|
||||
window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpen)
|
||||
window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClose)
|
||||
}
|
||||
}, [open, close])
|
||||
|
||||
return { note, blockId, loading, isOpen, open, close }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const TYPE_ICONS: Record<NoteType, React.ElementType> = {
|
||||
markdown: FileCode2,
|
||||
richtext: PenLine,
|
||||
checklist: ListChecks,
|
||||
daily: PenLine,
|
||||
}
|
||||
|
||||
const TYPE_I18N_KEYS: Record<NoteType, string> = {
|
||||
@@ -24,6 +25,7 @@ const TYPE_I18N_KEYS: Record<NoteType, string> = {
|
||||
markdown: 'notes.typeMarkdown',
|
||||
richtext: 'notes.typeRichText',
|
||||
checklist: 'notes.typeChecklist',
|
||||
daily: 'notes.typeDaily',
|
||||
}
|
||||
|
||||
interface NoteTypeSelectorProps {
|
||||
|
||||
@@ -141,9 +141,9 @@ export function EditorialNoteMenu({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateNote(note.id, { reminder }, { skipRevalidation: true })
|
||||
const patch = { reminder: reminder?.toISOString() ?? null }
|
||||
const patch = { reminder: (reminder?.toISOString() ?? null) as any }
|
||||
onNotePatch?.(note.id, patch)
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } as any })
|
||||
setShowReminder(false)
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
@@ -157,7 +157,8 @@ export function EditorialNoteMenu({
|
||||
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground"
|
||||
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label={t('notes.moreOptions') || 'Options'}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
@@ -396,6 +397,9 @@ export function NotesEditorialView({
|
||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||
className="space-y-4 group cursor-pointer relative pb-8"
|
||||
onClick={() => onOpen(note)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date don’t reorder wrongly */}
|
||||
<div
|
||||
@@ -436,8 +440,12 @@ export function NotesEditorialView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center justify-between">
|
||||
{title}
|
||||
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center gap-2 justify-between">
|
||||
<span className="inline-flex items-center gap-2 truncate">
|
||||
{note.isPinned && <Pin size={14} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
{note.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />}
|
||||
{title}
|
||||
</span>
|
||||
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
|
||||
<ChevronRight size={20} />
|
||||
</span>
|
||||
|
||||
@@ -46,9 +46,12 @@ import {
|
||||
FolderOpen,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
History,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
import dynamic from 'next/dynamic'
|
||||
const BrainstormConfirmDialog = dynamic(() => import('@/components/brainstorm/brainstorm-confirm-dialog').then(m => ({ default: m.BrainstormConfirmDialog })), { ssr: false })
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -348,6 +351,7 @@ export function NotesListViews({
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onGridReorder={onGridReorder}
|
||||
pinnedLabel={t('notes.pinned')}
|
||||
/>
|
||||
@@ -383,6 +387,7 @@ export function NotesListViews({
|
||||
<th
|
||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
aria-sort={sortColumn === 'modified' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableModified')} <SortIcon field="modified" />
|
||||
@@ -399,11 +404,15 @@ export function NotesListViews({
|
||||
<tr
|
||||
key={note.id}
|
||||
onClick={() => onOpen(note)}
|
||||
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group focus-visible:bg-foreground/[0.04]"
|
||||
>
|
||||
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
|
||||
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
{note.historyEnabled && <History size={11} className="text-emerald-500 shrink-0" />}
|
||||
{title}
|
||||
</span>
|
||||
</td>
|
||||
@@ -503,6 +512,7 @@ type GridCardSharedProps = {
|
||||
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
|
||||
onOpenHistory?: (note: Note) => void
|
||||
isOverlay?: boolean
|
||||
}
|
||||
|
||||
@@ -707,13 +717,23 @@ const GridCard = memo(function GridCard({
|
||||
aiIllustrationEnabled,
|
||||
onOpen,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onMoveToNotebook,
|
||||
onDeleteNote, onMoveToNotebook,
|
||||
onNoteIllustrationGenerated,
|
||||
onNoteIllustrationDeleted,
|
||||
onOpenHistory,
|
||||
isOverlay = false,
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const [brainstormNote, setBrainstormNote] = useState<Note | null>(null)
|
||||
|
||||
const handleBrainstormConfirm = (notebookId: string | null) => {
|
||||
if (!brainstormNote) return
|
||||
const seed = `${brainstormNote.title || untitled}\n\n${getNotePlainExcerpt(brainstormNote, 200)}`.trim()
|
||||
const params = new URLSearchParams({ seed: seed.slice(0, 300), sourceNoteId: brainstormNote.id })
|
||||
if (notebookId) params.set('notebookId', notebookId)
|
||||
router.push(`/brainstorm?${params.toString()}`)
|
||||
setBrainstormNote(null)
|
||||
}
|
||||
const { t, language } = useLanguage()
|
||||
const hydrated = useHydrated()
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
@@ -732,11 +752,11 @@ const GridCard = memo(function GridCard({
|
||||
|
||||
const handleBrainstormClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const seed = `${title}\n\n${excerpt}`.trim() || title
|
||||
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
|
||||
setBrainstormNote(note)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
@@ -756,6 +776,11 @@ const GridCard = memo(function GridCard({
|
||||
<Pin size={11} className="fill-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{note.historyEnabled && (
|
||||
<div className="absolute top-3 end-3 bg-emerald-500/15 dark:bg-emerald-500/25 border border-emerald-500/20 p-1 rounded-full shadow-sm backdrop-blur-sm text-emerald-600 dark:text-emerald-400">
|
||||
<History size={11} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
||||
<div className="space-y-2.5 flex-1">
|
||||
@@ -771,7 +796,7 @@ const GridCard = memo(function GridCard({
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 mt-auto border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
<span>{formattedDate}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover/card:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrainstormClick}
|
||||
@@ -808,6 +833,22 @@ const GridCard = memo(function GridCard({
|
||||
</button>
|
||||
</MoveToNotebookPicker>
|
||||
)}
|
||||
{onOpenHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenHistory(note) }}
|
||||
className={cn(
|
||||
"p-1 px-2 rounded-full transition-all cursor-pointer",
|
||||
note.historyEnabled
|
||||
? "text-emerald-500 hover:bg-emerald-500/10"
|
||||
: "text-muted-foreground/60 hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={note.historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")}
|
||||
aria-label={note.historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")}
|
||||
>
|
||||
<History size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
@@ -821,5 +862,13 @@ const GridCard = memo(function GridCard({
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<BrainstormConfirmDialog
|
||||
open={brainstormNote !== null}
|
||||
onClose={() => setBrainstormNote(null)}
|
||||
onConfirm={(notebookId) => { if (brainstormNote) handleBrainstormConfirm(notebookId) }}
|
||||
note={brainstormNote}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="name" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.name')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -78,6 +78,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -85,7 +86,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -97,6 +98,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -104,7 +106,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="password" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -116,6 +118,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('auth.passwordMinChars')}
|
||||
required
|
||||
minLength={6}
|
||||
@@ -124,7 +127,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="confirmPassword" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -136,6 +139,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
|
||||
@@ -110,6 +110,7 @@ type SlashItem = {
|
||||
shortcut?: string
|
||||
isImage?: boolean
|
||||
isAi?: boolean
|
||||
isFavorite?: boolean
|
||||
aiOption?: 'clarify' | 'shorten' | 'improve' | 'write'
|
||||
command: (editor: Editor, range?: any) => void
|
||||
}
|
||||
@@ -1256,7 +1257,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
<MobileEditorToolbar
|
||||
editor={editor}
|
||||
onOpenActionSheet={() => setActionSheetOpen(true)}
|
||||
onInsertImage={imageInsert.requestInsert}
|
||||
onInsertImage={imageInsert.requestInsert as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
interface SearchMatch {
|
||||
@@ -58,6 +59,7 @@ function escapeRegExp(s: string) {
|
||||
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const router = useRouter()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [useRegex, setUseRegex] = useState(false)
|
||||
@@ -370,11 +372,20 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
// Keyboard navigation + focus trap
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
else if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll<HTMLElement>('button, a, input, [tabindex]:not([tabindex="-1"])')
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
|
||||
}
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, filteredMatches.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)) }
|
||||
else if (e.key === 'Enter') {
|
||||
@@ -409,7 +420,14 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('searchModal.ariaLabel')}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -426,13 +444,13 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Rechercher dans toutes vos notes…"
|
||||
placeholder={t('searchModal.placeholder')}
|
||||
className="w-full text-sm pl-10 pr-28 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCaseSensitive(c => !c)}
|
||||
title="Respecter la casse"
|
||||
title={t('searchModal.caseSensitive')}
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
caseSensitive ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
@@ -441,7 +459,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseRegex(r => !r)}
|
||||
title="Mode regex"
|
||||
title={t('searchModal.regexMode')}
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
useRegex ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
@@ -457,7 +475,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
{/* Saved queries */}
|
||||
{savedQueries.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-concrete">
|
||||
<span className="uppercase text-[9px] font-bold">Favoris :</span>
|
||||
<span className="uppercase text-[9px] font-bold">{t('searchModal.favorites')}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedQueries.map(sq => (
|
||||
<button
|
||||
@@ -501,12 +519,12 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-concrete">
|
||||
{isLoading
|
||||
? 'Recherche en cours…'
|
||||
? t('searchModal.searching')
|
||||
: filteredMatches.length > 0
|
||||
? `${filteredMatches.length} occurrence${filteredMatches.length > 1 ? 's' : ''} dans ${docMatchesCount} note${docMatchesCount > 1 ? 's' : ''}`
|
||||
: query.trim()
|
||||
? 'Aucun résultat'
|
||||
: 'Tapez pour rechercher'}
|
||||
? t('searchModal.noResults')
|
||||
: t('searchModal.typeToSearch')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -525,7 +543,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
onChange={e => setSearchInTrash(e.target.checked)}
|
||||
className="rounded border-border/60 text-blueprint focus:ring-blueprint w-3 h-3"
|
||||
/>
|
||||
<span>Corbeille incluse</span>
|
||||
<span>{t('searchModal.trashIncluded')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -541,13 +559,13 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Sparkles size={12} className="text-brand-accent" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-brand-accent">
|
||||
{overviewLoading ? 'Analyse...' : 'Réponse IA'}
|
||||
{overviewLoading ? t('searchModal.aiAnalysis') : t('searchModal.aiResponse')}
|
||||
</span>
|
||||
</div>
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={12} className="animate-spin text-brand-accent" />
|
||||
<span className="text-[11px] text-muted-foreground">Synthèse des résultats...</span>
|
||||
<span className="text-[11px] text-muted-foreground">t('searchModal.resultsSummary')</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
|
||||
@@ -608,7 +626,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-24 space-y-2">
|
||||
<Search size={20} className="opacity-30 animate-pulse" />
|
||||
<p className="text-[11px] font-medium italic opacity-70">
|
||||
{query.trim() ? 'Aucune note ne correspond.' : 'Tapez pour obtenir des résultats instantanés.'}
|
||||
{query.trim() ? t('searchModal.noMatch') : t('searchModal.typeForResults')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -638,7 +656,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
{activeMatch.noteTitle}
|
||||
</h4>
|
||||
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-0.5">
|
||||
APERÇU CONTEXTUEL
|
||||
{t('searchModal.documentPreview')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -656,7 +674,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>Ouvrir dans l'éditeur</span>
|
||||
<span>{t('searchModal.openInEditor')}</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
|
||||
ID: {activeMatch.noteId.slice(0, 8)}…
|
||||
@@ -667,7 +685,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
|
||||
<HelpCircle size={24} className="opacity-25" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11.5px] font-bold">Aperçu du document</p>
|
||||
<p className="text-[11.5px] font-bold">{t('searchModal.documentPreview')}</p>
|
||||
<p className="text-[10px] italic opacity-60">
|
||||
Sélectionnez un résultat pour explorer son contenu.
|
||||
</p>
|
||||
@@ -682,20 +700,20 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/70">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">↑↓</kbd>
|
||||
naviguer
|
||||
{t('searchModal.hintNavigate')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Entrée</kbd>
|
||||
ouvrir
|
||||
{t('searchModal.hintOpen')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Échap</kbd>
|
||||
fermer
|
||||
{t('searchModal.hintClose')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
|
||||
<Command size={10} />
|
||||
<span>Memento Search</span>
|
||||
<span>{t('searchModal.title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
348
memento-note/components/sidebar-panels.tsx
Normal file
348
memento-note/components/sidebar-panels.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { getAllNotes, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import {
|
||||
ChevronRight, Lock, Plus, Pencil, Trash2, Sparkles, Clock,
|
||||
Pin, PinOff, GripVertical, FileText, Folder, FolderOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
export function NoteLink({
|
||||
title, isActive, isPinned, onClick,
|
||||
}: {
|
||||
title: string; isActive: boolean; isPinned?: boolean; onClick: () => void
|
||||
}) {
|
||||
const { language } = useLanguage()
|
||||
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: slideX }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<FileText size={12} className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')} />
|
||||
<span className="truncate flex-1">{title}</span>
|
||||
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarBrainstorms() {
|
||||
const { data: sessions, isLoading } = useBrainstormSessions()
|
||||
const deleteBrainstorm = useDeleteBrainstorm()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
{[1, 2, 3].map(i => <div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
|
||||
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
|
||||
<button onClick={() => router.push('/brainstorm')} className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium">
|
||||
{t('brainstorm.startOne')} →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{sessions.slice(0, 10).map(s => (
|
||||
<div key={s.id} className="relative group/item">
|
||||
<button
|
||||
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-brand-accent/5 group ${
|
||||
(s as any)._owned === false ? 'border-s-2 border-brand-accent/30 dark:border-brand-accent/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
||||
(s as any)._owned === false
|
||||
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||
}`}>
|
||||
<Sparkles size={12} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-medium truncate">
|
||||
{s.seedIdea}
|
||||
{(s as any)._owned === false && (
|
||||
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{(s as any)._owned !== false && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteBrainstorm.mutate(s.id) }}
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [reminders, setReminders] = useState<
|
||||
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
|
||||
>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
|
||||
const reload = () => { getNotesWithReminders().then((rows) => setReminders(rows as typeof reminders)) }
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
getNotesWithReminders().then((rows) => { if (!cancelled) setReminders(rows as typeof reminders) }).finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const handleComplete = async (e: React.MouseEvent, noteId: string) => {
|
||||
e.stopPropagation(); setTogglingId(noteId)
|
||||
await toggleReminderDone(noteId, true)
|
||||
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: true } : r))
|
||||
setTogglingId(null)
|
||||
}
|
||||
|
||||
const handleSnooze = async (e: React.MouseEvent, noteId: string) => {
|
||||
e.stopPropagation()
|
||||
const snoozeDate = new Date(Date.now() + 60 * 60 * 1000)
|
||||
setTogglingId(noteId)
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reminder: snoozeDate.toISOString(), isReminderDone: false }) })
|
||||
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, reminder: snoozeDate, isReminderDone: false } : r))
|
||||
} catch (e) { console.error('[silent-catch]', e); reload() }
|
||||
setTogglingId(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
|
||||
const overdue = active.filter((r) => new Date(r.reminder!) < now)
|
||||
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
|
||||
<div key={note.id} className="group flex items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-brand-accent/5 transition-colors">
|
||||
<button type="button" onClick={(e) => { void handleComplete(e, note.id) }} disabled={togglingId === note.id}
|
||||
className="flex-shrink-0 w-5 h-5 rounded-full border-2 border-concrete/30 hover:border-emerald-500 hover:bg-emerald-50 transition-all flex items-center justify-center"
|
||||
title={t('reminders.markDone') || 'Marquer comme fait'}>
|
||||
{togglingId === note.id ? <div className="w-2 h-2 rounded-full bg-concrete animate-pulse" /> : null}
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenNote(note.id, note.notebookId)} className="flex-1 text-start min-w-0">
|
||||
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">{note.title || t('notes.untitled')}</p>
|
||||
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
|
||||
{note.reminder && new Date(note.reminder).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5">
|
||||
<button type="button" onClick={(e) => { void handleSnooze(e, note.id) }} disabled={togglingId === note.id}
|
||||
className="p-1 rounded-md hover:bg-amber-100 text-amber-600 transition-colors" title={t('reminders.snooze1h') || 'Reporter de 1h'}>
|
||||
<span className="text-[9px] font-bold">+1h</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
{overdue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">{t('reminders.overdue')}</p>
|
||||
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
|
||||
</div>
|
||||
)}
|
||||
{upcoming.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">{t('reminders.upcoming')}</p>
|
||||
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarCarnetItem({
|
||||
carnet, isActive, notes, activeNoteId, onCarnetClick, onNoteClick, onAddSubNotebook, onRename, onDelete, onTogglePin, isPinned,
|
||||
children, isDragging, dragHandleProps, level, isExpanded, toggleExpand, hasChildNotebooks, hasActiveDescendant,
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
notes: { id: string; title: string; isPinned?: boolean }[]
|
||||
activeNoteId: string | null
|
||||
onCarnetClick: () => void
|
||||
onNoteClick: (noteId: string, carnetId: string) => void
|
||||
onAddSubNotebook: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
onTogglePin: () => void
|
||||
isPinned: boolean
|
||||
children?: React.ReactNode
|
||||
isDragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
level: number
|
||||
isExpanded: boolean
|
||||
toggleExpand: () => void
|
||||
hasChildNotebooks?: boolean
|
||||
hasActiveDescendant?: boolean
|
||||
}) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0 || notes.length > 0
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const handler = () => setContextMenu(null)
|
||||
window.addEventListener('click', handler)
|
||||
return () => window.removeEventListener('click', handler)
|
||||
}, [contextMenu])
|
||||
|
||||
return (
|
||||
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
|
||||
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY }) }}
|
||||
>
|
||||
{level > 0 && <div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />}
|
||||
{level > 0 && <div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />}
|
||||
<div className="flex-1 flex items-center gap-0.5">
|
||||
<div {...dragHandleProps}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<GripVertical size={11} />
|
||||
</div>
|
||||
{hasChildren ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground">
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : <div className="w-5" />}
|
||||
<motion.div
|
||||
whileHover={{ x: isRtl ? -2 : 2 }}
|
||||
onClick={onCarnetClick}
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden',
|
||||
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
|
||||
)}>
|
||||
{isActive && <motion.div layoutId="active-indicator" className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full" transition={{ type: 'spring', stiffness: 300, damping: 30 }} />}
|
||||
<div className={cn('w-5 h-5 flex items-center justify-center shrink-0 transition-colors', isActive ? 'text-brand-accent' : 'text-muted-foreground/80')}>
|
||||
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
||||
<span className={cn('text-[12px] font-medium transition-colors truncate', isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink')}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
{!isActive && hasActiveDescendant && <span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />}
|
||||
</div>
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors shrink-0 ms-auto">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute end-0 top-0 bottom-0 flex items-center gap-1 pe-1 ps-6 opacity-0 group-hover/item:opacity-100 transition-opacity bg-gradient-to-l from-[var(--color-paper,white)] dark:from-[var(--color-paper,#1a1a1a)] from-60% to-transparent">
|
||||
{isPinned && <span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}><Pin size={9} className="opacity-70" /></span>}
|
||||
<button onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }} className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink" title={t('notebook.createSubNotebook')}><Plus size={10} /></button>
|
||||
<button onClick={(e) => { e.stopPropagation(); onRename() }} className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink" title={t('notebook.rename')}><Pencil size={10} /></button>
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete() }} className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500" title={t('notebook.delete')}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{contextMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x, zIndex: 9999 }}
|
||||
className="bg-card border border-border rounded-xl shadow-xl py-1.5 min-w-[180px] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button onClick={() => { onTogglePin(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||
{isPinned ? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></> : <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>}
|
||||
</button>
|
||||
<div className="mx-3 my-1 border-t border-border/50" />
|
||||
<button onClick={() => { onAddSubNotebook(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||
<Plus size={13} className="text-concrete" /><span>{t('sidebar.newSubNotebook')}</span>
|
||||
</button>
|
||||
<button onClick={() => { onRename(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||
<Pencil size={13} className="text-concrete" /><span>{t('sidebar.renameNotebook')}</span>
|
||||
</button>
|
||||
<div className="mx-3 my-1 border-t border-border/50" />
|
||||
<button onClick={() => { onDelete(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors">
|
||||
<Trash2 size={13} /><span>{t('common.delete')}</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
|
||||
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
<div className="space-y-0.5 py-1">
|
||||
{children}
|
||||
{isExpanded && notes.map(note => (
|
||||
<NoteLink key={note.id} title={note.title} isPinned={note.isPinned} isActive={activeNoteId === note.id} onClick={() => onNoteClick(note.id, carnet.id)} />
|
||||
))}
|
||||
{isExpanded && notes.length === 0 && !hasChildren && (
|
||||
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">{t('sidebar.notebookEmpty')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,9 +48,13 @@ import { Notebook, Note } from '@/lib/types'
|
||||
import { toast } from 'sonner'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||
import { AiNotebookWizard } from './wizard/ai-notebook-wizard'
|
||||
import { NotificationPanel } from './notification-panel'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
|
||||
const AiNotebookWizard = dynamic(() => import('./wizard/ai-notebook-wizard').then(m => ({ default: m.AiNotebookWizard })), { ssr: false })
|
||||
const NotificationPanel = dynamic(() => import('./notification-panel').then(m => ({ default: m.NotificationPanel })), { ssr: false })
|
||||
const UsageMeter = dynamic(() => import('./usage-meter').then(m => ({ default: m.UsageMeter })), { ssr: false })
|
||||
const StarterPackBadge = dynamic(() => import('./onboarding/starter-pack-badge').then(m => ({ default: m.StarterPackBadge })), { ssr: false })
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -60,8 +64,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { performSignOut } from '@/lib/auth-client'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
import { StarterPackBadge } from './onboarding/starter-pack-badge'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
@@ -702,6 +704,28 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
||||
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
||||
const isImmersiveRoute = pathname === '/insights'
|
||||
|
||||
// Sur /insights : sidebar visible par défaut, user peut le cacher via toggle
|
||||
const [userCollapsed, setUserCollapsed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isImmersiveRoute) {
|
||||
const stored = localStorage.getItem('insights-sidebar-collapsed')
|
||||
setUserCollapsed(stored === 'true')
|
||||
}
|
||||
}, [isImmersiveRoute])
|
||||
|
||||
useEffect(() => {
|
||||
const onToggle = () => {
|
||||
setUserCollapsed(prev => {
|
||||
const next = !prev
|
||||
if (isImmersiveRoute) localStorage.setItem('insights-sidebar-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
window.addEventListener('toggle-insights-sidebar', onToggle)
|
||||
return () => window.removeEventListener('toggle-insights-sidebar', onToggle)
|
||||
}, [isImmersiveRoute])
|
||||
const isNotebooksRoute =
|
||||
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
||||
!pathname.startsWith('/settings') &&
|
||||
@@ -1157,11 +1181,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
// Mobile: fixed overlay, slide in/out
|
||||
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
||||
'h-full min-h-0 w-80 shrink-0 flex flex-row overflow-hidden',
|
||||
isImmersiveRoute && userCollapsed
|
||||
? 'fixed inset-y-0 start-0 z-[70]'
|
||||
: 'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
||||
'h-full min-h-0 w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 flex flex-row overflow-hidden self-stretch',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
|
||||
isImmersiveRoute && userCollapsed
|
||||
? '-translate-x-full'
|
||||
: (isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0'),
|
||||
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
@@ -1374,7 +1401,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div className="flex-1 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === 'notebooks' ? (
|
||||
|
||||
@@ -93,7 +93,7 @@ export const ChartExtension = Node.create({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView, {
|
||||
contentEditable: false,
|
||||
})
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,7 +128,7 @@ function ChartBlockView(props: any) {
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<NodeViewContent as="pre" className="p-4 bg-muted/30 rounded-b-lg overflow-x-auto" />
|
||||
<NodeViewContent as={"pre" as any} className="p-4 bg-muted/30 rounded-b-lg overflow-x-auto" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const MathEquationView = ({ node, updateAttributes, deleteNode, selected }: any)
|
||||
|
||||
const insertSymbol = (symbol: string) => {
|
||||
const el = inputRef.current
|
||||
if (!el) { setInput(prev => prev + symbol); return }
|
||||
if (!el) { setInput((prev: string) => prev + symbol); return }
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const newVal = input.slice(0, start) + symbol + input.slice(end)
|
||||
@@ -286,14 +286,14 @@ export const MathEquationExtension = Node.create({
|
||||
return [
|
||||
{
|
||||
find: /\$\$([^$]+)\$\$$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
handler: ({ state, range, match }: any) => {
|
||||
const latex = match[1]
|
||||
const tr = state.tr
|
||||
tr.deleteRange(range.from, range.to)
|
||||
tr.insert(range.from, state.schema.nodes.mathEquationBlock.create({ latex }))
|
||||
},
|
||||
},
|
||||
]
|
||||
] as any
|
||||
},
|
||||
})
|
||||
|
||||
@@ -338,7 +338,7 @@ export const InlineMathExtension = Node.create({
|
||||
return [
|
||||
{
|
||||
find: /(?:^|\s)\$([^$\n]+)\$$/,
|
||||
handler: ({ state, range, match }) => {
|
||||
handler: ({ state, range, match }: any) => {
|
||||
const fullMatch = match[0]
|
||||
const latex = match[1]
|
||||
const leadingSpace = fullMatch.startsWith(' ') ? ' ' : ''
|
||||
@@ -352,7 +352,7 @@ export const InlineMathExtension = Node.create({
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
] as any
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -154,12 +154,14 @@ export function useConvertIdea(sessionId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (ideaId: string) => {
|
||||
mutationFn: async (args: string | { ideaId: string; notebookId?: string | null }) => {
|
||||
const ideaId = typeof args === 'string' ? args : args.ideaId
|
||||
const notebookId = typeof args === 'string' ? null : args.notebookId
|
||||
const res = await fetch(`/api/brainstorm/${sessionId}/convert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ ideaId }),
|
||||
body: JSON.stringify({ ideaId, notebookId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(data.error || 'Failed to convert idea')
|
||||
|
||||
@@ -94,7 +94,6 @@ export async function fetchLiveModelsForProvider(
|
||||
.sort();
|
||||
|
||||
if (fetched.length > 0) {
|
||||
console.log(`[fetchLiveModelsForProvider] Got ${fetched.length} models from ${provider} API:`, fetched);
|
||||
return { models: fetched, fromApi: true };
|
||||
} else {
|
||||
console.warn(`[fetchLiveModelsForProvider] API returned empty data array for ${provider}`);
|
||||
|
||||
@@ -38,7 +38,6 @@ async function resolveProviderForLane(
|
||||
if (baseUrlConfigKey && byok.baseUrl) byokCfg[baseUrlConfigKey] = byok.baseUrl;
|
||||
|
||||
const resolvedModel = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName;
|
||||
console.log(`[byok] Using BYOK key: provider=${byok.provider} model=${resolvedModel} user=${billingUserId}`);
|
||||
|
||||
const provider = getProviderInstance(
|
||||
byok.provider as ProviderType,
|
||||
|
||||
@@ -119,14 +119,11 @@ async function fetchHtml(url: string): Promise<string | null> {
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
if (!res.ok) {
|
||||
console.log(`[fetchHtml] Failed for ${url}: ${res.status}`)
|
||||
return null
|
||||
}
|
||||
const html = await res.text()
|
||||
console.log(`[fetchHtml] Got ${html.length} chars from ${url}`)
|
||||
return html
|
||||
} catch (e: any) {
|
||||
console.log(`[fetchHtml] Error for ${url}: ${e.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -139,17 +136,14 @@ async function extractImagesForUrls(urls: string[]): Promise<Array<{ localUrl: s
|
||||
for (const pageUrl of urls) {
|
||||
const html = await fetchHtml(pageUrl)
|
||||
if (!html) {
|
||||
console.log(`[extractImagesForUrls] No HTML fetched for ${pageUrl}`)
|
||||
continue
|
||||
}
|
||||
const remoteUrls = extractImageUrlsFromHtml(html, pageUrl)
|
||||
console.log(`[extractImagesForUrls] ${remoteUrls.length} image URLs found on ${pageUrl}`)
|
||||
for (const remoteUrl of remoteUrls) {
|
||||
if (!seenSourceUrls.has(remoteUrl)) {
|
||||
seenSourceUrls.add(remoteUrl)
|
||||
toDownload.push({ sourceUrl: remoteUrl, pageUrl })
|
||||
} else {
|
||||
console.log(`[extractImagesForUrls] Skipping duplicate source: ${remoteUrl}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +157,6 @@ async function extractImagesForUrls(urls: string[]): Promise<Array<{ localUrl: s
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[extractImagesForUrls] ${toDownload.length} unique candidates, ${allImages.length} downloaded successfully`)
|
||||
return allImages
|
||||
}
|
||||
|
||||
@@ -1508,7 +1501,6 @@ async function executeToolUseAgent(
|
||||
|
||||
const registered = toolRegistry.get('generate_slides')
|
||||
if (registered) {
|
||||
console.log('[AgentExecutor] Running manual fallback execution for generate_slides')
|
||||
const slideTool = registered.buildTool(ctx)
|
||||
const executionResult = await slideTool.execute({ title, theme, slides })
|
||||
if (executionResult && executionResult.success && executionResult.canvasId) {
|
||||
@@ -1538,7 +1530,6 @@ async function executeToolUseAgent(
|
||||
if (diagramStr) {
|
||||
const registered = toolRegistry.get('generate_excalidraw')
|
||||
if (registered) {
|
||||
console.log('[AgentExecutor] Running manual fallback execution for generate_excalidraw')
|
||||
const excalidrawTool = registered.buildTool(ctx)
|
||||
const executionResult = await excalidrawTool.execute({ title, diagram: diagramStr })
|
||||
if (executionResult && executionResult.success && executionResult.canvasId) {
|
||||
@@ -1679,9 +1670,7 @@ async function executeToolUseAgent(
|
||||
// Extract images from scraped URLs and insert into markdown content
|
||||
let imageCount = 0
|
||||
if (agent.includeImages && scrapedUrls.length > 0) {
|
||||
console.log(`[AgentExecutor] Extracting images from ${scrapedUrls.length} URLs:`, scrapedUrls)
|
||||
const imageData = await extractImagesForUrls(scrapedUrls)
|
||||
console.log(`[AgentExecutor] Extracted ${imageData.length} images`)
|
||||
if (imageData.length > 0) {
|
||||
const currentNote = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
@@ -1697,7 +1686,6 @@ async function executeToolUseAgent(
|
||||
imageCount = imageData.length
|
||||
}
|
||||
} else if (agent.includeImages) {
|
||||
console.log(`[AgentExecutor] includeImages enabled but no scraped URLs found in tool results`)
|
||||
}
|
||||
|
||||
const resultId = canvasId || noteId
|
||||
|
||||
@@ -52,17 +52,16 @@ function getCached(key: string): SuggestChartsResponse | null {
|
||||
cache.delete(key)
|
||||
return null
|
||||
}
|
||||
console.log('[chart-suggestion] Cache hit for key:', key)
|
||||
return entry.data
|
||||
}
|
||||
|
||||
function setCached(key: string, data: SuggestChartsResponse): void {
|
||||
cache.set(key, { data, timestamp: Date.now() })
|
||||
// Limit cache size to 50 entries
|
||||
if (cache.size > 50) {
|
||||
const firstKey = cache.keys().next().value
|
||||
cache.delete(firstKey)
|
||||
}
|
||||
if (cache.size > 50) {
|
||||
const firstKey = cache.keys().next().value
|
||||
cache.delete(firstKey || '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,22 +73,18 @@ function setCached(key: string, data: SuggestChartsResponse): void {
|
||||
export async function suggestCharts(request: SuggestChartsRequest): Promise<SuggestChartsResponse> {
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(request.content || '', request.selection)
|
||||
console.log('[suggestCharts] Cache key:', cacheKey, 'contentLen:', request.content?.length, 'selectionLen:', request.selection?.length)
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached) {
|
||||
console.log('[suggestCharts] CACHE HIT - returning cached data')
|
||||
return cached
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] CACHE MISS - trying fast parser first')
|
||||
|
||||
// FAST PATH: Try regex parser first - NO AI call needed!
|
||||
const textToParse = request.selection || request.content || ''
|
||||
const parsed = parseChartData(textToParse)
|
||||
|
||||
if (parsed.hasData && parsed.confidence > 0.3) {
|
||||
console.log('[suggestCharts] FAST PATH - regex parser found data, skipping AI!')
|
||||
const suggestions = generateChartSuggestions(parsed.data)
|
||||
|
||||
const response: SuggestChartsResponse = {
|
||||
@@ -104,7 +99,6 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
|
||||
return response
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] Parser found no good data, calling AI API (slow...)')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggest-charts', {
|
||||
@@ -135,9 +129,7 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
|
||||
// Cache successful responses
|
||||
if (!data.error && !data.quotaExceeded) {
|
||||
setCached(cacheKey, data)
|
||||
console.log('[suggestCharts] Cached response for key:', cacheKey, 'cache size now:', cache.size)
|
||||
} else {
|
||||
console.log('[suggestCharts] NOT caching (has error or quota exceeded)')
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
@@ -426,7 +426,6 @@ export class ClusteringService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DBSCAN Clustering] Selected configuration: epsilon=${bestConfig.eps}, minSize=${bestConfig.minSize} -> Generated ${bestClusters.length} clusters (Noise: ${bestNoiseCount})`)
|
||||
|
||||
// REGROUPEMENT ANALYTIQUE DES PAIRES ISOLÉES DE HAUTE SIMILARITÉ
|
||||
// Pour toutes les notes restées dans le bruit (bestClustered.get(id) === -1) :
|
||||
@@ -470,7 +469,6 @@ export class ClusteringService {
|
||||
bestClustered.set(bestPairId, newCid)
|
||||
processedPairs.add(idA)
|
||||
processedPairs.add(bestPairId)
|
||||
console.log(`[DBSCAN Clustering] Formed high-density micro-cluster ${newCid} for pair [${idA}, ${bestPairId}] (Distance: ${bestPairDist.toFixed(4)})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,13 @@ export class ContextualAutoTagService {
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
console.log('[ContextualAutoTag] notebook not found for id:', notebookId)
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('[ContextualAutoTag] notebook:', notebook.name, 'labels:', notebook.labels.map((l: any) => l.name), 'contentLanguage:', contentLanguage)
|
||||
|
||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||
if (notebook.labels.length > 0) {
|
||||
const existing = await this.suggestFromExistingLabels(noteContent, notebook, contentLanguage)
|
||||
console.log('[ContextualAutoTag] existing label suggestions:', existing.length)
|
||||
if (existing.length > 0) return existing
|
||||
// Fallback: no existing label matched → suggest new ones too
|
||||
}
|
||||
@@ -113,7 +110,6 @@ ${labelList}
|
||||
Suggest relevant labels from the list above.`
|
||||
|
||||
const { text } = await generateText({ model, system: systemPrompt, prompt: userPrompt, temperature: 0.3 })
|
||||
console.log('[ContextualAutoTag] AI response (existing):', text?.substring(0, 300))
|
||||
|
||||
const parsed = this.parseAIResponse(text)
|
||||
if (!parsed) return []
|
||||
@@ -133,7 +129,6 @@ Suggest relevant labels from the list above.`
|
||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||
.slice(0, 3)
|
||||
|
||||
console.log('[ContextualAutoTag] filtered existing:', suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
console.error('Failed to suggest labels:', error)
|
||||
@@ -171,7 +166,6 @@ Note language detected: ${contentLanguage}
|
||||
Respond with labels in ${contentLanguage} (same language as the note).`
|
||||
|
||||
const { text } = await generateText({ model, system: systemPrompt, prompt: userPrompt, temperature: 0.3 })
|
||||
console.log('[ContextualAutoTag] AI response (new):', text?.substring(0, 300))
|
||||
|
||||
const parsed = this.parseAIResponse(text)
|
||||
if (!parsed) return []
|
||||
@@ -187,7 +181,6 @@ Respond with labels in ${contentLanguage} (same language as the note).`
|
||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||
.slice(0, 5)
|
||||
|
||||
console.log('[ContextualAutoTag] new labels:', suggestions.map((s: any) => `${s.label}(${s.confidence})`))
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
console.error('Failed to suggest new labels:', error)
|
||||
|
||||
@@ -176,7 +176,6 @@ export class MemoryEchoService {
|
||||
select: { aiProcessingConsent: true },
|
||||
})
|
||||
if (!userSettings?.aiProcessingConsent) {
|
||||
console.log(`[MemoryEchoService] User ${userId} has not given AI consent. Skipping connection generation for compliance.`)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -249,6 +248,9 @@ export class MemoryEchoService {
|
||||
const note1 = notesWithEmbeddings[i]
|
||||
const note2 = notesWithEmbeddings[j]
|
||||
|
||||
const baseThreshold = this.pairSimilarityThreshold(note1, note2, demoMode)
|
||||
const adjustedThreshold = baseThreshold + (notePenalty.get(note1.id) || 0) + (notePenalty.get(note2.id) || 0)
|
||||
|
||||
// Calculate time difference
|
||||
const daysApart = Math.abs(
|
||||
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
@@ -25,7 +25,6 @@ export class ScrapeService {
|
||||
targetUrl = 'https://' + url
|
||||
}
|
||||
|
||||
console.log(`[ScrapeService] Fetching ${targetUrl}...`)
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
|
||||
@@ -1128,8 +1128,6 @@ RULES:
|
||||
}),
|
||||
execute: async ({ title, diagram }) => {
|
||||
try {
|
||||
console.log('[Excalidraw Tool] INPUT title:', title)
|
||||
console.log('[Excalidraw Tool] INPUT diagram (first 500):', diagram?.substring(0, 500))
|
||||
|
||||
let elements: any[]
|
||||
let effectiveTitle = title
|
||||
@@ -1169,8 +1167,6 @@ RULES:
|
||||
elements = built.elements
|
||||
appliedStyle = style
|
||||
appliedType = effectiveType
|
||||
console.log('[Excalidraw Tool] Graph sanitize metrics:', sanitized.metrics)
|
||||
console.log('[Excalidraw Tool] Layout quality:', { ...built.layoutQuality, rankdir: built.rankdir, engine: built.engine, style, forcedAgentStyle, diagramType: effectiveType, zones: zones.length })
|
||||
} else if (parsed.elements && Array.isArray(parsed.elements)) {
|
||||
elements = parsed.elements.map((el: any, i: number) => normalizeElement(el, i))
|
||||
effectiveTitle = parsed.title || title || 'Diagram'
|
||||
@@ -1186,7 +1182,6 @@ RULES:
|
||||
return { success: false, error: 'No elements in diagram.' }
|
||||
}
|
||||
|
||||
console.log('[Excalidraw Tool] Elements count:', elements.length, '| Types:', [...new Set(elements.map((e: any) => e.type))])
|
||||
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
@@ -1202,7 +1197,6 @@ RULES:
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[Excalidraw Tool] Canvas created:', canvas.id, canvas.name)
|
||||
|
||||
// Immediately mark the AgentAction as success so frontend polling unblocks
|
||||
if (ctx.actionId) {
|
||||
|
||||
@@ -63,7 +63,6 @@ RULES:
|
||||
try {
|
||||
// Hard cap: never more than 8 slides regardless of what the model outputs
|
||||
const cappedSlides = slides.slice(0, 8)
|
||||
console.log('[Slides Tool] Building presentation:', title, '| Slides:', cappedSlides.length, '| Theme:', theme)
|
||||
|
||||
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
|
||||
|
||||
@@ -82,7 +81,6 @@ RULES:
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[Slides Tool] Canvas created:', canvas.id, '| Slides:', cappedSlides.length, '| Size:', Math.round(html.length / 1024), 'KB')
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user