feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,15 +6,17 @@ import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { emitNoteChange, NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { extractImagesFromHTML } from '@/lib/utils'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import type { TagSuggestion } from '@/lib/ai/types'
|
||||
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
|
||||
@@ -32,9 +34,9 @@ interface NoteEditorProviderProps {
|
||||
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
|
||||
const { data: session } = useSession()
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const queryClient = useQueryClient()
|
||||
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
@@ -64,10 +66,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const richTextEditorRef = useRef<RichTextEditorHandle>(null)
|
||||
const prevNoteRef = useRef(note)
|
||||
|
||||
useEffect(() => {
|
||||
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||
const prev = prevNoteRef.current
|
||||
|
||||
if (note.id !== prev.id) {
|
||||
setTitle(note.title || '')
|
||||
setContent(note.content)
|
||||
setCheckItems(note.checkItems || [])
|
||||
@@ -79,7 +84,37 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setIsMarkdown(note.type === 'markdown')
|
||||
setShowMarkdownPreview(note.type === 'markdown')
|
||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||
} else {
|
||||
if (note.title !== prev.title) setTitle(note.title || '')
|
||||
// Ne pas réinitialiser le contenu quand seuls images/links changent (post-save)
|
||||
if (note.content !== prev.content) setContent(note.content)
|
||||
if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) {
|
||||
setCheckItems(note.checkItems || [])
|
||||
}
|
||||
if (JSON.stringify(note.labels || []) !== JSON.stringify(prev.labels || [])) {
|
||||
setLabels(note.labels || [])
|
||||
}
|
||||
if (JSON.stringify(note.images || []) !== JSON.stringify(prev.images || [])) {
|
||||
setImages(note.images || [])
|
||||
}
|
||||
if (JSON.stringify(note.links || []) !== JSON.stringify(prev.links || [])) {
|
||||
setLinks(note.links || [])
|
||||
}
|
||||
if (note.color !== prev.color) setColor(note.color)
|
||||
if ((note.size || 'small') !== (prev.size || 'small')) setSize(note.size || 'small')
|
||||
const noteIsMarkdown = note.type === 'markdown'
|
||||
const prevIsMarkdown = prev.type === 'markdown'
|
||||
if (noteIsMarkdown !== prevIsMarkdown) {
|
||||
setIsMarkdown(noteIsMarkdown)
|
||||
setShowMarkdownPreview(noteIsMarkdown)
|
||||
}
|
||||
const prevReminder = prev.reminder ? new Date(prev.reminder as unknown as string).getTime() : null
|
||||
const nextReminder = note.reminder ? new Date(note.reminder as unknown as string).getTime() : null
|
||||
if (prevReminder !== nextReminder) {
|
||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||
}
|
||||
}
|
||||
|
||||
prevNoteRef.current = note
|
||||
}, [note])
|
||||
|
||||
@@ -178,6 +213,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||
setIsDirty(true)
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
@@ -198,6 +234,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||
setIsDirty(true)
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
@@ -233,6 +270,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
if (removedUrl) {
|
||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||
}
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
@@ -262,9 +300,22 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, isMarkdown]);
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, content, isMarkdown])
|
||||
|
||||
const resolveContentForSave = useCallback((): string => {
|
||||
if (!isMarkdown) {
|
||||
const editor = richTextEditorRef.current?.getEditor()
|
||||
if (editor) return editor.getHTML()
|
||||
}
|
||||
return content
|
||||
}, [content, isMarkdown])
|
||||
|
||||
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(contentToSave) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, isMarkdown])
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
const fullContentForAI = [
|
||||
@@ -281,6 +332,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsGeneratingTitles(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
@@ -347,6 +401,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsReformulating(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -385,6 +442,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -411,6 +471,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -437,6 +500,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -468,6 +534,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/transform-markdown', {
|
||||
@@ -506,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
setCurrentReminder(date)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
await updateNote(note.id, { reminder: date }, { skipRevalidation: true })
|
||||
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||
} catch {
|
||||
toast.error(t('notebook.savingReminder'))
|
||||
@@ -516,7 +585,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleRemoveReminder = async () => {
|
||||
setCurrentReminder(null)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
await updateNote(note.id, { reminder: null }, { skipRevalidation: true })
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
} catch {
|
||||
toast.error(t('notebook.removingReminder'))
|
||||
@@ -526,19 +595,23 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const contentToSave = resolveContentForSave()
|
||||
const imagesToSave = resolveImagesForSave(contentToSave)
|
||||
const result = await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content,
|
||||
content: contentToSave,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
images: imagesToSave,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
size,
|
||||
})
|
||||
}, { skipRevalidation: true })
|
||||
if (contentToSave !== content) setContent(contentToSave)
|
||||
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
|
||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
@@ -547,7 +620,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
onNoteSaved?.(result)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'updated', note: result })
|
||||
toast.success(t('notes.saved') || 'Note sauvegardée !')
|
||||
} catch (error) {
|
||||
console.error('[SAVE] updateNote failed:', error)
|
||||
@@ -630,7 +703,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'created', note: newNote })
|
||||
} catch (error) {
|
||||
console.error('Failed to copy note:', error)
|
||||
toast.error(t('notes.copyFailed'))
|
||||
@@ -640,12 +713,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleSaveInPlace = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const contentToSave = resolveContentForSave()
|
||||
const imagesToSave = resolveImagesForSave(contentToSave)
|
||||
const updatePayload = {
|
||||
title: title.trim() || null,
|
||||
content,
|
||||
content: contentToSave,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
images: imagesToSave,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
@@ -653,7 +728,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
size,
|
||||
}
|
||||
const result = await updateNote(note.id, updatePayload)
|
||||
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
|
||||
if (contentToSave !== content) setContent(contentToSave)
|
||||
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
|
||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
@@ -662,7 +739,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
onNoteSaved?.(result)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'updated', note: result })
|
||||
setIsDirty(false)
|
||||
toast.success(t('notes.saved') || 'Saved')
|
||||
} catch (error) {
|
||||
@@ -673,17 +750,36 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
|
||||
handleSaveInPlaceRef.current = handleSaveInPlace
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
handleSaveRef.current = handleSave
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSaveInPlace()
|
||||
void handleSaveInPlaceRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [fullPage, isSaving])
|
||||
}, [fullPage])
|
||||
|
||||
useEffect(() => {
|
||||
const onRequestSave = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ noteId?: string }>).detail
|
||||
if (detail?.noteId !== note.id || readOnly) return
|
||||
if (fullPage) {
|
||||
void handleSaveInPlaceRef.current()
|
||||
} else {
|
||||
void handleSaveRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
|
||||
return () => window.removeEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
|
||||
}, [note.id, fullPage, readOnly])
|
||||
|
||||
const state: NoteEditorState = useMemo(() => ({
|
||||
title,
|
||||
@@ -792,6 +888,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
globalLabels,
|
||||
fileInputRef,
|
||||
textareaRef,
|
||||
richTextEditorRef,
|
||||
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user