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:
@@ -14,7 +14,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function NoteContentArea() {
|
||||
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
|
||||
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
@@ -101,10 +101,12 @@ export function NoteContentArea() {
|
||||
return (
|
||||
<div className="fullpage-editor">
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={(v: string) => actions.setContent(v)}
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -113,10 +115,12 @@ export function NoteContentArea() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={actions.setContent}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
/>
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,6 +25,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface NoteEditorDialogProps {
|
||||
onClose: () => void
|
||||
@@ -33,6 +34,7 @@ interface NoteEditorDialogProps {
|
||||
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
await actions.handleSave()
|
||||
@@ -112,13 +114,10 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
|
||||
{/* Memory Echo Connections Section */}
|
||||
{!readOnly && (
|
||||
<EditorConnectionsSection
|
||||
<MemoryEchoSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
}}
|
||||
onCompareNotes={(noteIds: string[]) => {
|
||||
onCompareNotes={(noteIds: string[], meta?: { similarity?: number }) => {
|
||||
setComparisonSimilarity(meta?.similarity)
|
||||
Promise.all(noteIds.map(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
@@ -292,11 +291,24 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={!!state.comparisonNotes}
|
||||
onClose={() => actions.setComparisonNotes([])}
|
||||
onClose={() => {
|
||||
setComparisonSimilarity(undefined)
|
||||
actions.setComparisonNotes([])
|
||||
}}
|
||||
notes={state.comparisonNotes}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
similarity={comparisonSimilarity}
|
||||
onMergeNotes={async (noteIds: string[]) => {
|
||||
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
actions.setFusionNotes(fetchedNotes.filter((n): n is Partial<Note> => n !== null))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,20 +21,40 @@ import { LabelBadge } from '@/components/label-badge'
|
||||
import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
|
||||
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
|
||||
const [attachmentsCount, setAttachmentsCount] = useState(0)
|
||||
const [uploadTrigger, setUploadTrigger] = useState(0)
|
||||
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
||||
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const notes = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data as Partial<Note> : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
return notes.filter((n): n is Partial<Note> => n !== null)
|
||||
}
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
@@ -109,6 +129,19 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
|
||||
<NoteContentArea />
|
||||
|
||||
{!readOnly && (
|
||||
<MemoryEchoSection
|
||||
noteId={note.id}
|
||||
onCompareNotes={async (noteIds, meta) => {
|
||||
setComparisonSimilarity(meta?.similarity)
|
||||
actions.setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
actions.setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NoteAttachments
|
||||
noteId={note.id}
|
||||
onOpenDocQA={(att) => setDocQAAttachment(att)}
|
||||
@@ -149,6 +182,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
@@ -209,6 +244,55 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
onSave={actions.handleReminderSave}
|
||||
onRemove={actions.handleRemoveReminder}
|
||||
/>
|
||||
|
||||
{state.comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setComparisonSimilarity(undefined)
|
||||
actions.setComparisonNotes([])
|
||||
}}
|
||||
notes={state.comparisonNotes}
|
||||
similarity={comparisonSimilarity}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
actions.setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen
|
||||
onClose={() => actions.setFusionNotes([])}
|
||||
notes={state.fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await actions.handleSaveInPlace()
|
||||
const { createNote, updateNote } = await import('@/app/actions/notes')
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
|
||||
: state.fusionNotes[0].labels || [],
|
||||
color: state.fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: state.fusionNotes[0].notebookId ?? undefined,
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const fusionNote of state.fusionNotes) {
|
||||
if (fusionNote.id) {
|
||||
await updateNote(fusionNote.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
actions.setFusionNotes([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -39,7 +39,6 @@ interface NoteEditorToolbarProps {
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
|
||||
@@ -225,8 +224,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
refreshNotes(note.notebookId)
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.noteDeletedToast'))
|
||||
onClose()
|
||||
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
||||
@@ -366,9 +365,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
await leaveSharedNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.leftShare'))
|
||||
refreshNotes(note.notebookId)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useNoteEditorContext } from './note-editor-context'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { Loader2, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function NoteTitleBlock() {
|
||||
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
if (fullPage) {
|
||||
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||
@@ -64,6 +66,8 @@ export function NoteTitleBlock() {
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
|
||||
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import type { TagSuggestion } from '@/lib/ai/types'
|
||||
|
||||
@@ -129,4 +131,5 @@ export interface NoteEditorContextValue {
|
||||
globalLabels: Array<{ name: string }>
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
richTextEditorRef: RefObject<RichTextEditorHandle | null>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user