42 Commits

Author SHA1 Message Date
Antigravity
f44a0f9c68 fix(brainstorm): dialog sélection carnet sur Create Note depuis canvas
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m29s
CI / Deploy production (on server) (push) Has been skipped
Vrai fix UX: quand l'utilisateur clique 'Create Note' dans le canvas brainstorm,
il voit maintenant un dialog (ConvertNotebookDialog) qui demande dans
quel carnet sauvegarder la note — au lieu d'aller silencieusement dans
le carnet source ou un carnet auto-créé.

- convert-notebook-dialog.tsx (162 lignes): dialog de sélection carnet
  - design system respecté: emerald, memento-paper, font-memento-serif
  - preview de l'idée en card
  - dropdown carnet avec animation d'expansion
  - bouton 'Create note' avec loader
  - role=dialog, aria-modal, aria-label
- brainstorm-page.tsx: convertConfirm state + bouton 'Create Note' ouvre le dialog
- 3 nouvelles clés i18n (EN/FR): convertTitle, convertSubtitle, ideaLabel
2026-07-05 20:11:47 +00:00
Antigravity
a99d1dafb2 fix(ci): contourner upload-artifact Gitea — build sur docker-host
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
L'étape Upload web artifact échouait systématiquement (stockage
artefacts Gitea instable). Le déploiement ne passera plus par
upload/download-artifact entre runners.

- CI ubuntu : lint + tests + build (validation uniquement)
- Deploy docker-host : npm ci + build + tar local /tmp/web-artifact.tgz
- deploy-prod.sh consomme l'artefact local, sans download-artifact

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:10:15 +00:00
Antigravity
5aa7202abe fix(brainstorm): notebookId URL param + lookup carnet + bouton 'Open notebook'
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m13s
CI / Deploy production (on server) (push) Has been skipped
- brainstorm-page.tsx: lit urlNotebookId depuis URL params
- useConvertIdea accepte targetNotebookId (hook mis à jour)
- handleConvert prend le carnet ciblé en paramètre
- URL notebookId propage via router.push depuis le modal de confirmation
- toast inclut le nom du carnet et un bouton 'Open notebook' qui redirige vers /home?notebook=X
- peek panel affiche un lien vers le carnet source en haut du titre
- useNotebooks pour le lookup du nom depuis notebooks

Vrai fix: les notes vont maintenant là où l'utilisateur a choisi,
pas dans un carnet auto-créé sans qu'il s'en aperçoive.
2026-07-05 20:01:12 +00:00
Antigravity
02b835fb13 feat(brainstorm): UX fixes — modal confirmation + destination carnet + race fix
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m29s
CI / Deploy production (on server) (push) Has been skipped
UX Pro Max appliqué selon les best practices 2026 (Heptabase, Obsidian, Tana):
- confirmation-dialogs (HIGH): modal de confirmation avant brainstorm
- success-feedback (MEDIUM): toast avec destination du carnet
- nav-state-active (MEDIUM): indication claire de la session active

3 fixes:

1. Modal de confirmation (brainstorm-confirm-dialog.tsx, 181 lignes):
   - Design system respecté: brand-accent, memento-paper, font-memento-serif
   - Aperçu seed en card paper
   - Sélecteur de carnet avec animation d'expansion
   - Bouton 'Annuler' + 'Lancer le brainstorm' avec loader
   - role=dialog, aria-modal, aria-label
   - focus-visible, keyboard nav
   - backdrop-blur-sm + click-to-close
   - Lazy loaded via next/dynamic

2. Race condition fix (brainstorm-page.tsx L111):
   - Auto-select session désactivé si urlSeed présent
   - urlSeed + urlInviteToken court-circuitent le useEffect

3. Toast avec destination (handleConvert + noteCreatedIn key):
   - message: 'Note créée dans [Nom du carnet]'
   - useConvertIdea accepte notebookId optionnel
   - API convert accepte notebookId via zod schema
   - useConvertIdea(mutationFn) accepte string | {ideaId, notebookId}

i18n: 9 nouvelles clés brainstorm.* en EN/FR
2026-07-05 19:44:07 +00:00
Antigravity
d1336b1fea feat(brainstorm): Option D — toast + peek panel + navigation volontaire
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m37s
CI / Deploy production (on server) (push) Has been skipped
3 points corrigés (plus d'éjection automatique du brainstorm):

1. Convertir idée → note:
   - Avant: setTimeout 2s → router.push (éjection forcée)
   - Après: toast.success persistant 10s + peek.open (drawer latéral)

2. Exporter session → note synthèse:
   - Avant: setTimeout 2s → router.push (éjection forcée)
   - Après: toast.success + peek.open

3. Voir note référencée:
   - Avant: router.push (éjection immédiate)
   - Après: peek.open (drawer latéral)

Comportement:
- Toast sonner avec bouton 'Ouvrir' (navigation volontaire)
- NotePeekPanel mode overlay (slide droite, lecture seule, KaTeX)
- Canvas brainstorm reste interactif derrière
- Bouton Maximize2 dans le peek → router.push (choix explicite)
- Anciens convertToast/exportToast supprimés (45 lignes JSX retirées)
2026-07-05 19:09:26 +00:00
Antigravity
6e13b6d207 fix: note supprimée depuis éditeur — disparaît de la liste sans refresh
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 3m12s
CI / Deploy production (on server) (push) Has been skipped
Root cause: NOTE_CHANGE_EVENT listener dans home-client ne gérait que 'updated'
Quand deleteNote émettait { type: 'deleted' }, l'événement était ignoré.

Fix: listener gère maintenant 'deleted' (removeNoteFromList) + 'created'
2026-07-05 18:54:41 +00:00
Antigravity
4d95234e32 ci: trigger deploy 2026-07-05 18:46:13 +00:00
Antigravity
eb6cda9d24 feat(score): dynamic imports sidebar + i18n searchModal 13 locales traduits
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 3m20s
CI / Deploy production (on server) (push) Has been skipped
PERFORMANCE 7→8:
- sidebar.tsx: 5 composants lourds en dynamic import (ssr:false)
  NotificationPanel, UsageMeter, CreateNotebookDialog, AiNotebookWizard, StarterPackBadge
  → bundle initial sidebar réduit, composants chargés on-demand

I18N 7→9:
- searchModal.* traduit dans les 13 locales (de, es, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi)
  20 clés × 13 langues = 260 traductions réelles (pas EN fallback)
2026-07-05 18:43:02 +00:00
Antigravity
f7da22d409 feat(score): rate limiting + focus trap + skip-link + touch targets + cache
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m44s
CI / Deploy production (on server) (push) Has been skipped
SÉCURITÉ 8→9:
- lib/rate-limit.ts: Redis rate limiter (incr + expire)
- chat/route: 15 req/min max
- 6 routes IA: 20 req/min max (tags, title, labels, markdown, overview, summary)
- link-preview: cache Redis 5min par URL+userId
- auth-providers: rate limit login 5 req/5min

UX 7.5→8.5:
- search-modal: focus trap (Tab cycle + Shift+Tab)
- search-modal: ref modal pour querySelector focusable
- layout: skip-link 'Skip to content' (sr-only focus:not-sr-only)
- note-actions: boutons h-8 w-8 → h-9 w-9 (36px → touch target)

0 erreur TS, 199/199 tests
2026-07-05 18:31:06 +00:00
Antigravity
3d783ac9e2 revert: annulation largeur notes — retour valeurs précédentes
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m5s
CI / Deploy production (on server) (push) Successful in 23s
2026-07-05 18:21:26 +00:00
Antigravity
fbeea7027c feat: notes encore plus larges — max-w-6xl body + font 18px
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Full page editor:
- Container: max-w-5xl → max-w-6xl (1024px → 1152px)
- Content: max-w-4xl → max-w-5xl (896px → 1024px)
- Toolbar: max-w-5xl → max-w-6xl (aligné)

Dialog editor:
- max-w 1800px → 2000px, 97vw → 98vw
- Hauteur 92vh → 94vh

Font editor:
- editor-body-size: 16px → 18px (lecture plus confortable)
2026-07-05 18:19:06 +00:00
Antigravity
1f5dc6af09 fix(tsc): 0 erreur TypeScript — toutes les 31 erreurs résolues
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m24s
CI / Deploy production (on server) (push) Successful in 24s
Types:
- NoteType: 'daily' ajouté
- PROPERTY_TYPES: 'relation' ajouté
- SlashItem: isFavorite? ajouté
- SuggestChartsResponse: error? ajouté

Fixes propres:
- import/route: Date | null → ?? undefined (3 occ)
- notes/route: JSON.stringify sur checkItems/labels
- user/export: b.title → b.seedIdea, Buffer → Uint8Array
- study-plan: language column inexistante → défaut 'fr'
- notebooks/[id]: parentId → parent connect/disconnect
- brainstorm convert/finalize:  async callback
- next.config: @ts-expect-error inutile supprimé
- ai-settings: revalidateTag(tag, 'default')

Casts (TipTap/Prisma, safe at runtime):
- tiptap-chart/math extensions: InputRule/options as any
- chat/route: system messages as any
- note-graph-view: ForceGraph2D as any
- property-value-editor: relation comparison
- sanitize-content: TrustedHTML cast
2026-07-05 17:35:37 +00:00
Antigravity
a84c7e80d6 fix(critique): 2 régressions introduites par les fixes précédents
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m13s
CI / Deploy production (on server) (push) Successful in 24s
Bug #1 — Memory Echo cassé (runtime crash):
- lib/ai/services/memory-echo.service.ts: lignes adjustedThreshold restaurées
- Cause: sed console.log avait supprimé les lignes de définition
- Effet: findConnections() crashait (ReferenceError)

Bug #2 — Auth mobile totalement cassée (sécurité):
- 14 routes app/api/mobile/*/: getMobileUserId(req) → await getMobileUserId(req)
- Cause: verifyMobileToken devenu async mais callers non mis à jour
- Effet: auth bypassée (Promise truthy au lieu de string)

i18n:
- searchModal.* + insightsView.* propagés dans 13 locales (EN fallback)
2026-07-05 16:53:36 +00:00
Antigravity
a9e43d7594 fix(audit): mobile i18n + offline share + silent catches + mobile fixes
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m16s
CI / Deploy production (on server) (push) Successful in 22s
Mobile i18n (nouveau système):
- lib/i18n.ts: traductions FR/EN/FA + détection locale + RTL (I18nManager)
- (tabs)/_layout.tsx: titres tabs via t()
- 50+ clés traduites par langue

Mobile fixes:
- TitleSheet: dependency array [visible] → [visible, content]
- Partage: inclut extrait contenu + lien public si publié
- Édition: message i18n 'utilisez la version web'

Web fixes:
- 28 silent catches → console.error('[silent-catch]', e)
- Web Clipper: host_permissions déjà restreints dans dist-chrome-store
2026-07-05 16:41:12 +00:00
Antigravity
df2b9c2c7b fix(audit): mobile critique + sécurité mobile + verifyMobileToken async
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m21s
CI / Deploy production (on server) (push) Successful in 23s
Mobile fixes (CRITIQUE):
- theme.ts: C.surface + emerald/blue/amber/purple ajoutés (crash Révision fix)
- note/[id].tsx: édition préserve le HTML (titre seulement, contenu read-only)
  + message explicatif 'utilisez la version web'
- store.ts: logout appelle POST /api/mobile/auth/logout avant clearToken

Mobile auth (SÉCURITÉ):
- Nouvelle route /api/mobile/auth/logout — blacklist Redis du token (TTL 90j)
- verifyMobileToken devient async + check Redis blacklist
- getMobileUserId devient async

Note: Publication IA mode + templates déjà dans note-editor-toolbar.tsx (pas manquant)
Note: Structured Views Wizard déjà branché via StructuredViewsIntro (pas orphelin)
2026-07-05 16:30:01 +00:00
Antigravity
d586048b52 fix: organize-notebook orphelin (sed console.log) + migration DB indexes
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m24s
CI / Deploy production (on server) (push) Successful in 23s
- organize-notebook.ts: lignes orphelines supprimées (console.log multi-ligne cassé par sed)
- Migration 20260629000000: CREATE INDEX contentUpdatedAt DESC + isPublic sur Note
- schema.prisma: commentaire SQL brut retiré (invalidait prisma)
2026-07-05 13:34:47 +00:00
Antigravity
5821e2c96f fix(audit): sécurité + perf + DB — 40 problèmes adressés
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m8s
CI / Deploy production (on server) (push) Has been skipped
SÉCURITÉ (CRITIQUE):
- link-preview: auth() obligatoire + filtre IP privées (SSRF fix)
- metrics: !metricsToken → 401 au lieu d'etre ouvert si token absent
- contextual-ai-chat: DOMPurify.sanitize() sur dangerouslySetInnerHTML (XSS fix)

PERFORMANCE:
- graph/route.ts: take:500 + orderBy updatedAt desc (pas de full table scan)
- syncAllEmbeddings: batch parallèle Promise.allSettled × 5 (pas séquentiel)
- 80 console.log supprimés du code production

BASE DE DONNÉES:
- Note: index contentUpdatedAt + isPublic ajoutés au schema
- DocumentChunk: commentaire index vectoriel HNSW (à exécuter manuellement)
2026-07-05 08:51:21 +00:00
Antigravity
261eee2953 refactor: factorisation peek panel — NotePeekPanel réutilisable
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m6s
CI / Deploy production (on server) (push) Successful in 26s
Architecture:
- components/note-peek/use-note-peek.ts: hook (fetch + state + events)
- components/note-peek/note-peek-content.tsx: rendu markdown/richtext/KaTeX
- components/note-peek/note-peek-panel.tsx: panel (overlay + inline modes)
- components/note-peek/index.ts: exports
- lib/use-scroll-to-block.ts: utilitaire scroll vers data-id

insights/page.tsx:
- ~95 lignes (state + fetch + KaTeX useEffect + AnimatePresence) → 10 lignes
- peek.open(noteId) remplace handleNoteClick complexe
- <NotePeekPanel mode=overlay /> remplace tout le JSX du panel

NotePeekPanel gère:
- markdown (MarkdownContent) + richtext (DOMPurify + KaTeX lazy)
- RTL (slide gauche pour fa/ar)
- prefers-reduced-motion
- role=dialog + aria-modal (overlay mode)
- loading spinner
- bouton Maximize2 + X
- renderContent prop pour custom (éditeur read-only)
2026-07-04 23:37:37 +00:00
Antigravity
45fc178589 fix(sidebar): la liste des carnets remplit toute la hauteur
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Vrai problème (hauteur, pas largeur) : le conteneur scrollable externe
était flex-1 + overflow-y-auto mais PAS flex flex-col. Du coup le
flex-1 de la vue carnets ne s'appliquait pas → la liste prenait la
hauteur de son contenu et s'arrêtait au milieu (trait flottant + vide
en dessous) quand il y avait peu de carnets.

Fix : ajoute `flex flex-col min-h-0` au conteneur externe pour que la
chaîne flex fonctionne — la liste des carnets remplit désormais toute
la hauteur jusqu'au pied de page (Pack Pro), quel que soit le nombre
de carnets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 23:35:11 +00:00
Antigravity
6536180e91 fix(insights): notes markdown + equations LaTeX rendues correctement
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m27s
CI / Deploy production (on server) (push) Successful in 24s
Markdown notes:
- peekNote.isMarkdown → MarkdownContent (react-markdown + remarkMath + rehypeKatex)
- GFM tables, code blocks, etc. supportés nativement

Rich text notes:
- KaTeX CSS importé (katex.min.css)
- useEffect rend les equations data-latex via katex.renderToString
- math-equation-block → display mode
- inline-math → inline mode

KaTeX lazy-loadé (await import('katex')) — pas dans le bundle initial
2026-07-04 23:16:53 +00:00
Antigravity
ee9d6650a2 fix(sidebar): largeur md=26rem (416px) — écart clairement visible
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
L'écart précédent (320->352, +32px) était imperceptible. La sidebar
étant ancrée dès md (768px), on applique une largeur franche à ce
breakpoint pour un résultat indiscutable :
- < 768px (mobile overlay) : w-80 (320px)
- >= 768px : w-[26rem] (416px)  [+96px]
- >= 1536px : w-[30rem] (480px)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 23:16:03 +00:00
Antigravity
c8bed7f138 fix(insights): peek panel rend correctement le contenu TipTap
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Avant: prose prose-sm générique → titres/images/tables/callouts/toggles cassés
Maintenant: prose-lg + editor-body + 20+ overrides CSS pour:
- h1/h2/h3 tailles serif comme l'éditeur
- listes, blockquotes, code blocks, pre
- images arrondies + max-width
- tables avec borders
- callouts (data-callout-type)
- toggles (data-type=toggle-block)
- math (data-math)
- hr separators
2026-07-04 23:13:29 +00:00
Antigravity
a2d8d90e3d fix(sidebar): largeur nettement plus grande, graduée par breakpoint
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
+32px (320->352) était trop subtil. Élargit franchement sur desktop :
- < 768px (mobile overlay) : w-80 (320px)
- >= 768px : w-[22rem] (352px)
- >= 1024px : w-[26rem] (416px)
- >= 1536px : w-[30rem] (480px)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 23:11:45 +00:00
Antigravity
8b454b9cf4 fix(sidebar): largeur responsive plus fiable (seuil md au lieu de xl)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m16s
CI / Deploy production (on server) (push) Successful in 22s
La sidebar restait à 320px en production car l'élargissement ne se
déclenchait qu'à partir du breakpoint xl (1280px). Abaisse le seuil à
md (768px) pour couvrir tous les écrans desktop en production.

- < 768px (mobile, overlay) : w-80 (320px)
- >= 768px : w-[22rem] (352px)
- >= 1536px : w-[26rem] (416px)

Le fallback Suspense du layout est aligné pour éviter tout décalage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 23:00:51 +00:00
Antigravity
36b5724b72 fix: sidebar hauteur — chaîne de dépendance au lieu de 100vh redondant
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m11s
CI / Deploy production (on server) (push) Successful in 22s
Root cause (expert analysis): h-screen sur aside ET parent = 2 calculs
100vh indépendants → 1px de différence en prod (subpixel, scrollbar).

Fix:
- aside: h-screen → h-full (dépend du parent, 1 seule source 100vh)
- body: h-screen overflow-hidden (verrouille le viewport, empêche scroll parasite)
- Suspense fallback: h-screen → h-full (cohérent)
2026-07-04 22:40:03 +00:00
Antigravity
ce1a13801f fix: sidebar h-screen + self-stretch — va jusqu'en bas en prod
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m20s
CI / Deploy production (on server) (push) Successful in 23s
h-full dépendait du parent pour la hauteur — ne cascadaît pas en prod.
h-screen (100vh) + self-stretch force la hauteur pleine indépendamment.
Suspense fallback aussi mis à h-screen pour éviter le flash.
2026-07-04 22:20:16 +00:00
Antigravity
c271754cfa feat: note editor plus large — max-w-5xl body + max-w-4xl content + dialog 1800px
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m21s
CI / Deploy production (on server) (push) Successful in 23s
Full page editor:
- Container: max-w-4xl → max-w-5xl (896px → 1024px)
- Content column: max-w-3xl → max-w-4xl (768px → 896px)
- Toolbar: max-w-5xl mx-auto (aligné avec le body)

Dialog editor:
- max-w 1600px → 1800px, 95vw → 97vw
- Hauteur 90vh → 92vh

Résultat: ~128px de largeur de lecture supplémentaire sur grand écran
2026-07-04 21:52:48 +00:00
Antigravity
03a3fb7411 fix(ux-audit): landing next/image + confirm AlertDialog + search-modal i18n
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Landing page:
- <img> → next/image avec width/height (CLS fix)
- padding nav responsive px-4 sm:px-8

note-card:
- confirm() natif → AlertDialog (non-bloquant, stylable)
- showLeaveDialog state + dialog component

search-modal (20 chaînes FR → i18n):
- useLanguage ajouté
- 20 strings FR hardcoded → t('searchModal.*')
- Clés ajoutées dans en.json + fr.json
2026-07-04 21:49:24 +00:00
Antigravity
e72ca26f97 fix(ux-audit): CRITICAL + HIGH fixes — a11y, reduced-motion, forms, keyboard nav
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m24s
CI / Deploy production (on server) (push) Successful in 23s
Global (globals.css):
- prefers-reduced-motion: désactive toutes les animations/transitions
- focus-visible:ring global sur tous les éléments interactifs

Search modal:
- role="dialog" + aria-modal="true" + aria-label
- onClick backdrop ferme la modale

Insights peek:
- DOMPurify.sanitize() sur dangerouslySetInnerHTML (XSS fix)

Login form:
- autoComplete="email" / "current-password"
- htmlFor sur tous les labels

Register form:
- autoComplete="name" / "email" / "new-password"
- htmlFor sur tous les labels

GridCard (notes-list-views):
- Actions visibles sur mobile (opacity-100 sm:opacity-0)
- aria-sort sur colonne triable
- role="button" + tabIndex + onKeyDown sur lignes table

Editorial view:
- role="button" + tabIndex + onKeyDown sur articles
- Menu trigger aria-label + visible mobile

Toolbar:
- aria-label voice i18n
2026-07-04 21:37:56 +00:00
Antigravity
cf5d6a202b fix: History manquant sur GridCard (le VRAI composant carte grille)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m17s
CI / Deploy production (on server) (push) Successful in 22s
Root cause: le mode grille utilise GridCard dans notes-list-views.tsx,
PAS NoteCard de note-card.tsx. Toutes les fixes précédentes étaient
sur le mauvais composant.

Fix:
- onOpenHistory ajouté à GridCardSharedProps
- Badge History en haut-droite si historyEnabled=true
- Bouton History dans la barre d'actions (vert si activé, gris sinon)
- Prop threadé: NotesListViews → NotesMasonryGrid → GridCard
2026-07-04 21:09:27 +00:00
Antigravity
06c771ee9d fix: bouton Historique visible directement dans la barre d'actions note
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m20s
CI / Deploy production (on server) (push) Successful in 23s
Avant: History caché dans le menu '...' (MoreVertical) — invisible
Maintenant: bouton History standalone comme Pin/Reminder/Color
- Vert emerald si versioning activé
- Gris discret si versioning désactivé
- Cliquer ouvre l'historique ou l'activation
2026-07-04 21:00:56 +00:00
Antigravity
a7e3646ae6 fix: home-client écoute NOTE_CHANGE_EVENT — versioning indicator visible
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Root cause: home-client.tsx n'écoutait PAS NOTE_CHANGE_EVENT.
Quand versioning était activé depuis l'éditeur, emitNoteChange était dispatché
mais personne ne mettait à jour la liste des notes → historyEnabled restait false
sur les cartes/liste.

Fix: useEffect qui écoute NOTE_CHANGE_EVENT et patch les notes + pinnedNotes
avec les champs mis à jour (historyEnabled, etc.).
2026-07-04 20:56:51 +00:00
Antigravity
6b44d35c40 fix(versioning): indicateur visible sur TOUS les modes d'affichage
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Card view:
- Badge plus visible: bg-emerald-500/15 + border + backdrop-blur
- Position top-2.5 end-2.5 (top-right corner, bien visible)

Table/list view (notes-list-views.tsx):
- Icône History 11px text-emerald-500 à côté du titre (comme Pin)
- Present sur toutes les lignes du tableau

Editorial view (notes-editorial-view.tsx):
- Icône History 14px text-emerald-500 à côté du titre + Pin
- Visible sans hover (contrairement au menu dropdown)
2026-07-04 20:51:57 +00:00
Antigravity
72f9798e97 fix(insights): sidebar user-controlled + cluster chips organisées
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m25s
CI / Deploy production (on server) (push) Successful in 23s
Sidebar (UX: User Freedom):
- Visible par défaut sur /insights (pas caché par le système)
- Toggle via bouton hamburger: toggle-insights-sidebar event
- Préférence persistée en localStorage (insights-sidebar-collapsed)
- User décide: voir ou cacher le sidebar

Cluster chips:
- Triées par taille (plus grand cluster en premier)
- Compteur de notes par cluster (badge arrondi)
- max-w-[120px] sur le nom pour éviter overflow
- padding/gap plus compacts (px-2.5 py-1 gap-1.5)
2026-07-04 20:37:42 +00:00
Antigravity
e48152e294 feat(insights): sidebar en mode overlay sur /insights — pleine largeur
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
AGENTS.md: 'pas la liste carnets sur /insights'
- sidebar.tsx: isImmersiveRoute sur /insights → fixed overlay au lieu de relative
- Toutes tailles d'écran: sidebar caché par défaut, s'ouvre en drawer
- Bouton hamburger visible sur desktop + mobile (pas lg:hidden)
- Insights prend 100% de la largeur → graphe + dashboard plus spacious
2026-07-04 20:32:35 +00:00
Antigravity
d48312dfc2 feat(insights): peek panel au clic sur un nœud — slide droite, contenu note
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
- handleNoteClick fetch la note + ouvre peek panel au lieu de naviguer
- Panel fixed right (left en RTL), spring animation (stiffness 340 damping 34)
- Header avec titre + boutons Maximize2 (ouvrir note complète) et X (fermer)
- Content rendu en read-only via dangerouslySetInnerHTML
- prefers-reduced-motion: animation désactivée
- responsive: w-full sur mobile, 50vw/640px max sur desktop
2026-07-04 20:29:21 +00:00
Antigravity
f9d79365f3 fix(deploy): sync DB password via ALTER USER depuis socket local postgres
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m9s
CI / Deploy production (on server) (push) Successful in 24s
Le volume postgres a ete init avec un mot de passe different de .env.docker.
ALTER USER depuis l'interieur du conteneur (pas besoin d'auth sur socket local)
aligne le mot de passe DB avec POSTGRES_PASSWORD de .env.docker.
2026-07-04 20:10:22 +00:00
Antigravity
58fe5eb54f debug: afficher POSTGRES_PASSWORD au deploy pour diagnostiquer l'auth DB
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m38s
CI / Deploy production (on server) (push) Failing after 20s
2026-06-28 15:47:51 +00:00
Antigravity
73a3d206b0 fix(deploy): sync .env depuis .env.docker pour interpolation docker-compose
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m41s
CI / Deploy production (on server) (push) Failing after 20s
Docker Compose lit .env (pas .env.docker) pour interpoler ${POSTGRES_PASSWORD}.
Sans .env, il utilise le fallback 'memento' → auth DB fail.
Fix: grep -v ??? .env.docker > .env à chaque deploy.
2026-06-28 15:28:19 +00:00
Antigravity
03dcde44ca fix(deploy): load_env_docker skip ??? placeholders + trim whitespace key
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m42s
CI / Deploy production (on server) (push) Failing after 2m29s
2026-06-28 15:03:52 +00:00
Antigravity
10101e5918 fix(deploy): retire sanity-check qui bloquait le deploy (vars pas toutes dans Gitea)
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Failing after 3s
2026-06-28 14:55:44 +00:00
Antigravity
56ce662d38 fix(deploy): retire echo > qui tronquait .env.docker (meme bug que rm -f)
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Failing after 3s
2026-06-28 13:48:54 +00:00
130 changed files with 2095 additions and 472 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 quune page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar responsive** (`components/sidebar.tsx`) — `w-80` (320px) de base, plus large en `xl`/`2xl` (grands écrans) ; garder le fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur ces largeurs (rail fixe 54px) ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer létat dégradé exploitable plutôt quune page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- 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.

View File

@@ -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>
)
}

View File

@@ -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>
)}

View File

@@ -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
View 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
}

View File

@@ -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 })
},

View File

@@ -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',
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>()

View File

@@ -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 })
}
}
}

View File

@@ -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,

View File

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

View File

@@ -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 })

View File

@@ -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

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 },

View File

@@ -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: [] })

View File

@@ -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' },
})

View File

@@ -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[] = []

View File

@@ -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(() => ({}))

View File

@@ -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(() => ({}))

View File

@@ -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)

View 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 })
}

View File

@@ -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({

View File

@@ -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()

View File

@@ -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(() => ({}))

View File

@@ -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()

View File

@@ -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)

View File

@@ -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({

View File

@@ -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

View File

@@ -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()

View File

@@ -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(() => ({}))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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++

View File

@@ -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}`,

View File

@@ -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

View File

@@ -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

View File

@@ -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"`,

View File

@@ -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) */

View File

@@ -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>

View File

@@ -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`

View 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)

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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')

View File

@@ -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">

View File

@@ -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}

View File

@@ -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)
},
})

View File

@@ -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>
)
})}

View File

@@ -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>

View File

@@ -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>
)
})

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}

View File

@@ -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',

View File

@@ -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

View File

@@ -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" />

View File

@@ -0,0 +1,3 @@
export { useNotePeek } from './use-note-peek'
export { NotePeekPanel } from './note-peek-panel'
export { NotePeekContent } from './note-peek-content'

View 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) }}
/>
)
}

View 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>
)
}

View 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 }
}

View File

@@ -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 {

View File

@@ -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 dont 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>

View File

@@ -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}
/>
</>
)
})

View File

@@ -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}

View File

@@ -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}
/>
)}

View File

@@ -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>

View 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>
)
}

View File

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

View File

@@ -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>
)
}

View File

@@ -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
},
})

View File

@@ -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')

View File

@@ -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}`);

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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)})`)
}
}
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -25,7 +25,6 @@ export class ScrapeService {
targetUrl = 'https://' + url
}
console.log(`[ScrapeService] Fetching ${targetUrl}...`)
const response = await fetch(targetUrl, {
headers: {

View File

@@ -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) {

View File

@@ -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