feat: AI Overview recherche + AI Writer inline streaming
- AI Overview : synthèse IA en haut des résultats de recherche (Ctrl+K) - Service search-overview.service.ts - Endpoint /api/ai/search-overview - Carte 'Réponse IA' avec Sparkles en haut du panneau gauche - Pur additif, ne modifie pas le classement des résultats - AI Writer inline : slash menu → 'Écrire avec l'IA' → champ inline - Mode 'write' dans paragraph-refactor.service.ts - Streaming paragraphe par paragraphe (120ms delay) - Nettoyage HTML (espaces vides supprimés) - Ref synchrone pour éviter fermeture du menu pendant la frappe - Fix: index slashCommands AI Writer corrigé - Fix: Loader2 import manquant - i18n FR/EN
This commit is contained in:
@@ -632,6 +632,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [trashCount, setTrashCount] = useState(0)
|
||||
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
// Ref stable pour éviter les stale closures dans les handlers de drop
|
||||
const draggedIdRef = useRef<string | null>(null)
|
||||
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
@@ -852,61 +854,85 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
// ── Drag state ──
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(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<SortOrder, string> = {
|
||||
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 && (
|
||||
<div className="absolute -top-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
||||
)}
|
||||
{/* Indicateur "déposer après" */}
|
||||
{dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
|
||||
<div className="absolute -bottom-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
||||
)}
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
id: notebook.id,
|
||||
@@ -1481,17 +1521,27 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
<div className="my-3 h-px bg-border/40" />
|
||||
|
||||
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
|
||||
<div
|
||||
className={`mb-2 h-10 rounded-lg border-2 border-dashed flex items-center justify-center gap-2 text-[11px] font-medium transition-all duration-150 ${
|
||||
draggedId
|
||||
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent/70 opacity-100 cursor-copy pointer-events-auto'
|
||||
: 'border-transparent bg-transparent text-transparent opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDragEnter={(e) => { e.preventDefault() }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
{t('sidebar.dropToRoot')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
|
||||
{t('sidebar.dropToRoot')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user