diff --git a/memento-note/app/(main)/settings/layout.tsx b/memento-note/app/(main)/settings/layout.tsx
index 2267f60..5ec76e2 100644
--- a/memento-note/app/(main)/settings/layout.tsx
+++ b/memento-note/app/(main)/settings/layout.tsx
@@ -2,12 +2,14 @@
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings'
+import { useLanguage } from '@/lib/i18n'
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
+ const { t } = useLanguage()
return (
(null)
@@ -1736,9 +1737,48 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
]
const closeMenu = useCallback(() => {
- setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null)
+ setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null); setAiWriterMode(false); aiWriterModeRef.current = false
}, [])
+ const handleAiWriterSubmit = useCallback(async () => {
+ if (!aiWriterPrompt.trim()) return
+ setAiWriterLoading(true)
+ try {
+ const res = await fetch('/api/ai/reformulate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ text: '',
+ option: 'write',
+ format: 'html',
+ writePrompt: aiWriterPrompt.trim(),
+ }),
+ })
+ const data = await res.json()
+ if (!res.ok) { toast.error(data.error || 'Erreur'); return }
+ let html = data.reformulatedText || data.text || ''
+ // Clean up excessive whitespace
+ html = html
+ .replace(/\s*<\/p>/g, '')
+ .replace(/(
]*>)\s+/g, '$1')
+ .replace(/\s+(<\/p>)/g, '$1')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+
+ closeMenu()
+
+ // Stream the content paragraph by paragraph
+ const paragraphs = html.match(/<(?:p|h[1-3]|ul|ol|div|blockquote|pre)[^>]*>[\s\S]*?<\/(?:p|h[1-3]|ul|ol|div|blockquote|pre)>/gi) || [html]
+ for (let i = 0; i < paragraphs.length; i++) {
+ editor.chain().focus().insertContent(paragraphs[i]).run()
+ if (i < paragraphs.length - 1) {
+ await new Promise(r => setTimeout(r, 120))
+ }
+ }
+ } catch (e: any) { toast.error(e.message || 'Erreur') }
+ finally { setAiWriterLoading(false) }
+ }, [aiWriterPrompt, editor, closeMenu])
+
const deleteSlashText = useCallback(() => {
const { from, to } = editor.state.selection
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
@@ -1767,9 +1807,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (item.isImage) {
deleteSlashText(); closeMenu(); onInsertImage(editor)
} else if (item.isAi && item.aiOption === 'write') {
- deleteSlashText(); closeMenu()
- setAiWriterOpen(true)
+ aiWriterModeRef.current = true
+ setAiWriterMode(true)
setAiWriterPrompt('')
+ deleteSlashText()
+ const { from } = editor.state.selection
+ const c = editor.view.coordsAtPos(from)
+ setCoords({ top: c.bottom + 8, left: c.left })
} else if (item.isAi && item.aiOption) {
deleteSlashText(); closeMenu(); setAiLoading(true)
try {
@@ -1797,43 +1841,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
}
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
- const handleAiWriterSubmit = useCallback(async () => {
- if (!aiWriterPrompt.trim() || !editor) return
- setAiWriterLoading(true)
- try {
- const consented = await requestAiConsent()
- if (!consented) return
-
- const noteTitle = (editor.storage as any).structuredViewBlock?.noteTitle || ''
- const res = await fetch('/api/ai/reformulate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- text: noteTitle,
- option: 'write',
- format: 'html',
- language: 'Francais',
- writePrompt: aiWriterPrompt.trim(),
- }),
- })
- const data = await res.json()
- if (!res.ok) {
- toast.error(data.error || 'Erreur')
- return
- }
- const html = data.reformulatedText || data.text || ''
- if (html) {
- editor.chain().focus().insertContent(html).run()
- }
- setAiWriterOpen(false)
- setAiWriterPrompt('')
- } catch (err: any) {
- toast.error(err.message || 'Erreur')
- } finally {
- setAiWriterLoading(false)
- }
- }, [aiWriterPrompt, editor, requestAiConsent])
-
// Charger les favoris fréquents lors de l'ouverture
useEffect(() => {
if (!isOpen) return
@@ -1913,6 +1920,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return
+ if (aiWriterModeRef.current) return // Let the AI writer input handle keystrokes
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
@@ -1973,7 +1981,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
useEffect(() => {
const handleClick = (e: MouseEvent) => {
- if (isOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ if (isOpen && !aiWriterModeRef.current && menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
}
@@ -1985,6 +1993,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const handler = () => {
// Ignore events fired while user clicks inside the menu
if (menuInteracting.current) return
+ if (aiWriterModeRef.current) return // Synchronous check via ref
const { from, empty } = editor.state.selection
if (!empty) { if (isOpen) closeMenu(); return }
const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
@@ -1992,16 +2001,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (m) {
setQuery(m[1])
setSelectedIndex(0)
- if (!isOpen) { setIsOpen(true); setActiveCategory(null) } // reset category filter on fresh open
+ if (!isOpen) { setIsOpen(true); setActiveCategory(null) }
}
else if (isOpen) closeMenu()
}
editor.on('update', handler)
editor.on('selectionUpdate', handler)
return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) }
- }, [editor, isOpen, closeMenu])
+ }, [editor, isOpen, closeMenu, aiWriterMode])
- if (!isOpen || filtered.length === 0) return null
+ if (!aiWriterMode && (!isOpen || filtered.length === 0)) return null
let flatIndex = -1
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
@@ -2021,6 +2030,42 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
return createPortal(
<>
+ {aiWriterMode ? (
+
e.stopPropagation()}
+ dir="auto"
+ >
+
+
+ setAiWriterPrompt(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation()
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleAiWriterSubmit() }
+ if (e.key === 'Escape') closeMenu()
+ }}
+ autoFocus
+ disabled={aiWriterLoading}
+ placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'}
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+ />
+ {aiWriterLoading && }
+
+ {!aiWriterLoading && aiWriterPrompt.trim() && (
+
+ )}
+
+ ) : (
)}
-
- {aiWriterOpen && createPortal(
- {
- const { from } = editor.state.selection
- const c = editor.view.coordsAtPos(from)
- return c.bottom + 4
- })(),
- left: (() => {
- const { from } = editor.state.selection
- const c = editor.view.coordsAtPos(from)
- return Math.min(c.left, window.innerWidth - 420)
- })(),
- }}
- dir="auto"
- >
-
- setAiWriterPrompt(e.target.value)}
- onKeyDown={(e) => {
- e.stopPropagation()
- if (e.key === 'Enter') { e.preventDefault(); handleAiWriterSubmit() }
- if (e.key === 'Escape') { setAiWriterOpen(false); setAiWriterPrompt('') }
- }}
- autoFocus
- disabled={aiWriterLoading}
- placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'}
- className="flex-1 min-w-[300px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
- />
- {aiWriterLoading && }
- {!aiWriterLoading && aiWriterPrompt.trim() && (
-
- )}
-
,
- document.body
- )}
>,
document.body
)
diff --git a/memento-note/components/search-modal.tsx b/memento-note/components/search-modal.tsx
index c10028b..da4a692 100644
--- a/memento-note/components/search-modal.tsx
+++ b/memento-note/components/search-modal.tsx
@@ -12,6 +12,8 @@ import {
HelpCircle,
Command,
FileText,
+ Sparkles,
+ Loader2,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
@@ -64,6 +66,8 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [savedQueries, setSavedQueries] = useState([])
const [results, setResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
+ const [overview, setOverview] = useState(null)
+ const [overviewLoading, setOverviewLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef(null)
@@ -137,6 +141,36 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
}
}, [query, searchInTrash])
+ // Fetch AI overview after results load
+ useEffect(() => {
+ if (!query.trim() || results.length === 0) {
+ setOverview(null)
+ return
+ }
+ setOverviewLoading(true)
+ const timer = setTimeout(async () => {
+ try {
+ const topResults = results.slice(0, 5).map(r => ({
+ title: r.title || 'Sans titre',
+ snippet: r.content?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) || '',
+ }))
+ const res = await fetch('/api/ai/search-overview', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: query.trim(), results: topResults }),
+ })
+ const data = await res.json()
+ setOverview(data.hasRelevantInfo ? data.answer : null)
+ } catch {
+ setOverview(null)
+ } finally {
+ setOverviewLoading(false)
+ }
+ }, 500)
+
+ return () => clearTimeout(timer)
+ }, [query, results])
+
// Reset selected index when results change
useEffect(() => {
setSelectedIndex(0)
@@ -501,6 +535,25 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
{/* Left — results list */}
+ {/* AI Overview */}
+ {(overviewLoading || overview) && (
+
+
+
+
+ {overviewLoading ? 'Analyse...' : 'Réponse IA'}
+
+
+ {overviewLoading ? (
+
+
+ Synthèse des résultats...
+
+ ) : (
+
{overview}
+ )}
+
+ )}
{filteredMatches.map((m, idx) => {
const isSelected = idx === selectedIndex
return (
diff --git a/memento-note/components/sidebar.tsx b/memento-note/components/sidebar.tsx
index 22ea3e4..234df14 100644
--- a/memento-note/components/sidebar.tsx
+++ b/memento-note/components/sidebar.tsx
@@ -632,6 +632,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [trashCount, setTrashCount] = useState(0)
const [draggedId, setDraggedId] = useState
(null)
+ // Ref stable pour éviter les stale closures dans les handlers de drop
+ const draggedIdRef = useRef(null)
const [orderedNotebooks, setOrderedNotebooks] = useState([])
const dragOverId = useRef(null)
const isSavingRef = useRef(false)
@@ -852,61 +854,85 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
// ── Drag state ──
const [dropTarget, setDropTarget] = useState(null)
- const [dropAction, setDropAction] = useState<'into' | null>(null)
+ const [dropAction, setDropAction] = useState<'into' | 'before' | 'after' | null>(null)
- const handleDragStart = (e: React.DragEvent, notebookId: string) => {
+ const handleDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
+ // Stocker dans le ref ET dans le state
+ // Le ref est toujours frais dans les handlers async (pas de stale closure)
+ draggedIdRef.current = notebookId
setDraggedId(notebookId)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', notebookId)
- }
+ }, [])
- const handleDragEnd = () => {
+ const handleDragEnd = useCallback(() => {
+ draggedIdRef.current = null
setDraggedId(null)
dragOverId.current = null
isSavingRef.current = false
setDropTarget(null)
setDropAction(null)
setOrderedNotebooks(sortedNotebooks)
- }
+ }, [sortedNotebooks])
- const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => {
+ const handleDropOnNotebook = useCallback(async (e: React.DragEvent, targetId: string, action: 'into' | 'before' | 'after') => {
e.preventDefault()
e.stopPropagation()
+ const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
+ draggedIdRef.current = null
+ setDraggedId(null)
setDropTarget(null)
setDropAction(null)
- const dragId = draggedId || e.dataTransfer.getData('text/plain')
- if (!dragId || dragId === targetId) {
- setDraggedId(null)
- return
- }
- setDraggedId(null)
dragOverId.current = null
+ if (!dragId || dragId === targetId) return
try {
- await moveNotebookToParent(dragId, targetId)
+ if (action === 'into') {
+ await moveNotebookToParent(dragId, targetId)
+ } else {
+ // before/after : placer au même niveau que la cible et réordonner
+ const target = orderedNotebooks.find(nb => nb.id === targetId)
+ const newParentId = target?.parentId ?? null
+
+ // 1. Mettre à jour le parentId si nécessaire
+ const dragged = orderedNotebooks.find(nb => nb.id === dragId)
+ if (dragged?.parentId !== newParentId) {
+ await moveNotebookToParent(dragId, newParentId)
+ }
+
+ // 2. Recalculer l'ordre des siblings
+ const siblings = orderedNotebooks
+ .filter(nb => (nb.parentId ?? null) === newParentId && nb.id !== dragId)
+ const targetIdx = siblings.findIndex(nb => nb.id === targetId)
+ const insertAt = action === 'before' ? targetIdx : targetIdx + 1
+ siblings.splice(insertAt, 0, { id: dragId } as typeof siblings[0])
+ // Passer en tri manuel pour que l'ordre soit respecté visuellement
+ setSortOrder('manual')
+ await updateNotebookOrderOptimistic(siblings.map(nb => nb.id))
+ }
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
- }
+ }, [moveNotebookToParent, updateNotebookOrderOptimistic, orderedNotebooks, sortedNotebooks, t])
- const handleDropToRoot = async (e: React.DragEvent) => {
+ const handleDropToRoot = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
+ // Lire depuis le ref (toujours à jour, pas de stale closure)
+ const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
+ // Nettoyer APRÈS avoir lu la valeur
+ draggedIdRef.current = null
+ setDraggedId(null)
setDropTarget(null)
setDropAction(null)
- const dragId = draggedId || e.dataTransfer.getData('text/plain')
- if (!dragId) {
- setDraggedId(null)
- return
- }
- setDraggedId(null)
dragOverId.current = null
+ if (!dragId) return
try {
await moveNotebookToParent(dragId, null)
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
- }
+ }, [moveNotebookToParent, sortedNotebooks, t])
const sortLabels: Record = {
newest: t('sidebar.sortNewest'),
@@ -1033,8 +1059,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
e.stopPropagation()
if (!draggedId || draggedId === notebook.id) return
e.dataTransfer.dropEffect = 'move'
+ const rect = e.currentTarget.getBoundingClientRect()
+ const y = e.clientY - rect.top
+ const pct = y / rect.height
setDropTarget(notebook.id)
- setDropAction('into')
+ if (pct < 0.3) setDropAction('before')
+ else if (pct > 0.7) setDropAction('after')
+ else setDropAction('into')
}}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget as Node)) return
@@ -1046,15 +1077,24 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
- handleDropOnNotebook(e, notebook.id)
+ const action = (dropTarget === notebook.id ? dropAction : null) ?? 'into'
+ handleDropOnNotebook(e, notebook.id, action as 'into' | 'before' | 'after')
}}
className={cn(
- 'rounded-lg transition-colors',
+ 'relative rounded-lg transition-colors',
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
isDragging && 'opacity-50'
)}
>
+ {/* Indicateur "déposer avant" */}
+ {dropTarget === notebook.id && dropAction === 'before' && draggedId && draggedId !== notebook.id && (
+
+ )}
+ {/* Indicateur "déposer après" */}
+ {dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
+
+ )}
+ {/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
+ { e.preventDefault(); e.stopPropagation() }}
+ onDragEnter={(e) => { e.preventDefault() }}
+ >
+
+ {t('sidebar.dropToRoot')}
+
+
e.preventDefault()}
>
{renderCarnetTree(undefined, 0)}
- {draggedId && (
-
- {t('sidebar.dropToRoot')}
-
- )}
diff --git a/memento-note/lib/ai/services/search-overview.service.ts b/memento-note/lib/ai/services/search-overview.service.ts
new file mode 100644
index 0000000..f77dedd
--- /dev/null
+++ b/memento-note/lib/ai/services/search-overview.service.ts
@@ -0,0 +1,57 @@
+import { getChatProvider } from '../factory'
+import { getSystemConfig } from '@/lib/config'
+
+export interface SearchOverviewResult {
+ answer: string
+ hasRelevantInfo: boolean
+}
+
+export class SearchOverviewService {
+ async generate(
+ query: string,
+ results: Array<{ title: string; snippet: string }>,
+ language?: string
+ ): Promise
{
+ if (results.length === 0) {
+ return { answer: '', hasRelevantInfo: false }
+ }
+
+ const lang = language || 'fr'
+ const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English'
+
+ const topResults = results.slice(0, 8)
+ const context = topResults.map((r, i) =>
+ `[Note ${i + 1}: ${r.title}]\n${r.snippet.slice(0, 300)}`
+ ).join('\n\n---\n\n')
+
+ const prompt = `Tu es un assistant de recherche. Réponds à la question de l'utilisateur en te basant UNIQUEMENT sur les notes fournies ci-dessous.
+
+LANGUE DE RÉPONSE : ${langName}
+
+QUESTION : "${query}"
+
+NOTES TROUVÉES :
+${context}
+
+RÈGLES :
+- Réponds en ${langName} UNIQUEMENT
+- Sois concis : 2-4 phrases maximum
+- Si les notes contiennent l'info, réponds directement
+- Si les notes ne contiennent PAS l'info, réponds exactement : "NO_RELEVANT_INFO"
+- Ne dis pas "selon les notes" — réponds naturellement
+- Cite les titres des notes pertinentes entre parenthèses à la fin si utile`
+
+ const config = await getSystemConfig()
+ const provider = getChatProvider(config)
+ const raw = await provider.generateText(prompt)
+
+ const answer = raw.trim()
+ if (answer === 'NO_RELEVANT_INFO' || answer.length < 10) {
+ return { answer: '', hasRelevantInfo: false }
+ }
+
+ return { answer, hasRelevantInfo: true }
+ }
+}
+
+export const searchOverviewService = new SearchOverviewService()