feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 14:27:29 +00:00
parent 077e665dfc
commit e2672cd2c2
323 changed files with 20670 additions and 42431 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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