refactor: split NoteEditor into focused components + consolidate contexts

Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-08 14:31:08 +00:00
parent a58610003d
commit 91b1201112
35 changed files with 2606 additions and 344 deletions

View File

@@ -0,0 +1,37 @@
'use client'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteEditorFullPage } from './note-editor-full-page'
import { NoteEditorDialog } from './note-editor-dialog'
import { Note } from '@/lib/types'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
fullPage?: boolean
}
export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
</NoteEditorProvider>
)
}
// Re-export context hook for backwards compatibility
export { useNoteEditorContext } from './note-editor-context'
// Re-export sub-components for advanced usage
export { NoteEditorFullPage } from './note-editor-full-page'
export { NoteEditorDialog } from './note-editor-dialog'
export { NoteEditorProvider } from './note-editor-context'
export { NoteTitleBlock } from './note-title-block'
export { NoteContentArea } from './note-content-area'
export { NoteMetadataSection } from './note-metadata-section'
export { NoteEditorToolbar } from './note-editor-toolbar'

View File

@@ -0,0 +1,179 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { RichTextEditor } from '@/components/rich-text-editor'
import { MarkdownContent } from '@/components/markdown-content'
import { MarkdownSlashCommands } from '@/components/markdown-slash-commands'
import { GhostTags } from '@/components/ghost-tags'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { X, Plus } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
export function NoteContentArea() {
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
const { t } = useLanguage()
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
if (state.noteType === 'richtext') {
if (fullPage) {
return (
<div className="fullpage-editor">
<RichTextEditor
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
)
}
return (
<RichTextEditor
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
)
}
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
return (
<div
className={cn(
'min-h-[280px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed',
fullPage ? '' : 'p-3 rounded-md border border-border/40 bg-muted/20'
)}
onClick={() => !readOnly && actions.setShowMarkdownPreview(false)}
>
<MarkdownContent content={state.content} />
{!readOnly && (
<p className="text-[11px] text-foreground/30 mt-8 select-none not-prose italic">
Cliquez pour éditer
</p>
)}
</div>
)
}
if (state.noteType === 'markdown' || state.noteType === 'text') {
if (fullPage) {
return (
<div className="relative">
<textarea
ref={textareaRef}
dir="auto"
placeholder={t('notes.takeNote') || "Commencez à écrire… tapez '/' pour les commandes"}
value={state.content}
onFocus={() => actions.setShowMarkdownPreview(false)}
onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly}
className="w-full min-h-[280px] border-0 outline-none px-0 bg-transparent editor-body leading-relaxed resize-none overflow-hidden placeholder:text-foreground/30 text-foreground"
/>
{!readOnly && (
<MarkdownSlashCommands
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
value={state.content}
onChange={(v: string) => actions.setContent(v)}
/>
)}
</div>
)
}
// Dialog mode
return (
<div className="space-y-2">
<Textarea
dir="auto"
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
value={state.content}
onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
readOnly && "cursor-default"
)}
/>
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
</div>
)
}
// Checklist mode
if (fullPage) {
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div>
)
}
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div>
)
}

View File

@@ -0,0 +1,796 @@
'use client'
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
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 { useAutoTagging } from '@/hooks/use-auto-tagging'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
interface NoteEditorProviderProps {
note: Note
readOnly?: boolean
fullPage?: boolean
children: ReactNode
}
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
// Core content state
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
const [images, setImages] = useState<string[]>(note.images || [])
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
// Refs
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevNoteRef = useRef(note)
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
setTitle(note.title || '')
setContent(note.content)
setCheckItems(note.checkItems || [])
setLabels(note.labels || [])
setImages(note.images || [])
setLinks(note.links || [])
setColor(note.color)
setSize(note.size || 'small')
setNoteType(note.type)
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
prevNoteRef.current = note
}, [note])
// Update context notebookId when note changes
useEffect(() => {
setContextNotebookId(note.notebookId || null)
}, [note.notebookId, setContextNotebookId])
// Auto-tagging hook
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: noteType !== 'checklist' && autoLabelingEnabled
})
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null
)
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
// Title suggestions state
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
// Reformulation state
const [isReformulating, setIsReformulating] = useState(false)
const [reformulationModal, setReformulationModal] = useState<{
originalText: string
reformulatedText: string
option: string
} | null>(null)
// AI processing state
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Memory Echo Connections state
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
// Tags dismissed by the user for this session
const [dismissedTags, setDismissedTags] = useState<string[]>([])
// Filter suggestions to exclude dismissed ones
// and those already present on the note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
})
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
}
// Paste handler: upload clipboard images
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
}
}
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t, noteType])
// Auto-grow textarea as content grows
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content])
// Also auto-grow when switching FROM preview TO edit mode
useEffect(() => {
if (showMarkdownPreview) return // we're in preview, textarea not mounted
// Defer one frame so the textarea is in the DOM
const raf = requestAnimationFrame(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
el.focus()
})
return () => cancelAnimationFrame(raf)
}, [showMarkdownPreview])
const handleRemoveImage = (index: number) => {
const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index))
// Track removed images for cleanup on save
if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl])
}
}
const handleAddLink = async () => {
if (!linkUrl) return
setShowLinkDialog(false)
try {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
toast.success(t('notes.linkAdded'))
} else {
toast.warning(t('notes.linkMetadataFailed'))
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
}
}
const handleRemoveLink = (index: number) => {
setLinks(links.filter((_, i) => i !== index))
}
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]);
const handleGenerateTitles = async () => {
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
.trim()
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
setIsGeneratingTitles(true)
try {
const response = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fullContentForAI }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError'))
}
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
} catch (error: any) {
console.error('Error generating titles:', error)
toast.error(error.message || t('ai.titleGenerationFailed'))
} finally {
setIsGeneratingTitles(false)
}
}
const handleSelectTitle = (title: string) => {
setTitle(title)
setTitleSuggestions([])
toast.success(t('ai.titleApplied'))
}
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
const selectedText = window.getSelection()?.toString()
if (!selectedText && (!content || content.trim().length === 0)) {
toast.error(t('ai.reformulationNoText'))
return
}
let textToReformulate: string
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
textToReformulate = selectedText
} else {
textToReformulate = content
if (selectedText) {
toast.info(t('ai.reformulationSelectionTooShort'))
}
}
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
setIsReformulating(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: textToReformulate,
option: option
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError'))
}
const data = await response.json()
setReformulationModal({
originalText: data.originalText,
reformulatedText: data.reformulatedText,
option: data.option
})
} catch (error: any) {
console.error('Error reformulating:', error)
toast.error(error.message || t('ai.reformulationFailed'))
} finally {
setIsReformulating(false)
}
}
const handleClarifyDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'clarify' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Clarify error:', error)
toast.error(t('notes.clarifyFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleShortenDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'shorten' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Shorten error:', error)
toast.error(t('notes.shortenFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleImproveDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'improve' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
toast.error(t('notes.improveFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContent(data.transformedText)
setNoteType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
} catch (error) {
console.error('Transform to markdown error:', error)
toast.error(t('ai.transformError'))
} finally {
setIsProcessingAI(false)
}
}
const handleApplyRefactor = () => {
if (!reformulationModal) return
const selectedText = window.getSelection()?.toString()
if (selectedText) {
setContent(reformulationModal.reformulatedText)
} else {
setContent(reformulationModal.reformulatedText)
}
setReformulationModal(null)
toast.success(t('ai.reformulationApplied'))
}
const handleReminderSave = async (date: Date) => {
if (date < new Date()) {
toast.error(t('notes.reminderPastError'))
return
}
setCurrentReminder(date)
try {
await updateNote(note.id, { reminder: date })
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
} catch {
toast.error(t('notebook.savingReminder'))
}
}
const handleRemoveReminder = async () => {
setCurrentReminder(null)
try {
await updateNote(note.id, { reminder: null })
toast.success(t('notes.reminderRemoved'))
} catch {
toast.error(t('notebook.removingReminder'))
}
}
const handleSave = async () => {
setIsSaving(true)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) {
console.error('Failed to save note:', error)
} finally {
setIsSaving(false)
}
}
const handleCheckItem = (id: string) => {
setCheckItems(items =>
items.map(item =>
item.id === id ? { ...item, checked: !item.checked } : item
)
)
}
const handleUpdateCheckItem = (id: string, text: string) => {
setCheckItems(items =>
items.map(item => (item.id === id ? { ...item, text } : item))
)
}
const handleAddCheckItem = () => {
setCheckItems([
...checkItems,
{ id: Date.now().toString(), text: '', checked: false },
])
}
const handleRemoveCheckItem = (id: string) => {
setCheckItems(items => items.filter(item => item.id !== id))
}
const handleSelectGhostTag = async (tag: string) => {
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setLabels(prev => [...prev, tag])
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Error creating auto-label:', err)
}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
}
const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label))
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content,
color: color,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: noteType === 'markdown',
type: noteType,
size: size,
})
toast.success(t('notes.copySuccess'))
triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
triggerRefresh()
setIsDirty(false)
toast.success('Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
toast.error('Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
}
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSaveInPlace()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [fullPage, isSaving])
// Build state object
const state: NoteEditorState = useMemo(() => ({
title,
content,
checkItems,
labels,
images,
links,
newLabel,
color: color as NoteColor,
size,
noteType,
showMarkdownPreview,
removedImageUrls,
isSaving,
isDirty,
isProcessingAI,
aiOpen,
infoOpen,
isGeneratingTitles,
titleSuggestions,
dismissedTitleSuggestions,
isReformulating,
reformulationModal,
previousContentForCopilot,
showReminderDialog,
currentReminder,
showLinkDialog,
linkUrl,
comparisonNotes,
fusionNotes,
dismissedTags,
filteredSuggestions,
isAnalyzingSuggestions,
isMarkdown,
allImages,
colorClasses,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
// Build actions object
const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
setCheckItems,
handleCheckItem,
handleUpdateCheckItem,
handleAddCheckItem,
handleRemoveCheckItem,
setLabels: (l) => { setLabels(l); setIsDirty(true) },
handleSelectGhostTag,
handleDismissGhostTag,
handleRemoveLabel,
setImages,
handleImageUpload,
handleRemoveImage,
uploadImageFile,
setLinks,
handleAddLink,
handleRemoveLink,
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
setColor: (c) => { setColor(c); setIsDirty(true) },
setSize: (s) => { setSize(s); setIsDirty(true) },
setShowReminderDialog,
setCurrentReminder,
handleReminderSave,
handleRemoveReminder,
setShowLinkDialog,
setLinkUrl,
handleGenerateTitles,
handleSelectTitle,
handleReformulate,
handleApplyRefactor,
handleClarifyDirect,
handleShortenDirect,
handleImproveDirect,
handleTransformMarkdown,
handleSave,
handleSaveInPlace,
handleMakeCopy,
setComparisonNotes,
setFusionNotes,
setReformulationModal,
setIsDirty,
setAiOpen,
setInfoOpen,
setIsProcessingAI,
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
}), [])
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name })),
globalLabels,
fileInputRef,
textareaRef,
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
return (
<NoteEditorContext.Provider value={value}>
{children}
</NoteEditorContext.Provider>
)
}
export function useNoteEditorContext() {
const context = useContext(NoteEditorContext)
if (context === undefined) {
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
}
return context
}

View File

@@ -0,0 +1,345 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { NoteMetadataSection } from './note-metadata-section'
import { EditorImages } from '@/components/editor-images'
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
interface NoteEditorDialogProps {
onClose: () => void
}
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const handleSaveAndClose = async () => {
await actions.handleSave()
onClose()
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
state.colorClasses
)}
>
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
<DialogHeader>
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
</div>
{readOnly && (
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
{t('notes.readOnly')}
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
{/* Title */}
<NoteTitleBlock />
{/* Title Suggestions */}
{!readOnly && state.titleSuggestions.length > 0 && (
<div>
{/* TitleSuggestions component */}
</div>
)}
{/* Images */}
<EditorImages images={state.images} onRemove={actions.handleRemoveImage} />
{/* Link Previews */}
{state.links.length > 0 && (
<div className="flex flex-col gap-2">
{state.links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => actions.handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Content Area */}
<NoteContentArea />
{/* Metadata Section */}
<NoteMetadataSection />
{/* Memory Echo Connections Section */}
{!readOnly && (
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
}}
onCompareNotes={(noteIds: string[]) => {
Promise.all(noteIds.map(async (id: string) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) {
console.error(`Failed to fetch note ${id}`)
return null
}
const data = await res.json()
if (data.success && data.data) {
return data.data
}
return null
} catch (error) {
console.error(`Error fetching note ${id}:`, error)
return null
}
}))
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
.then(fetchedNotes => {
actions.setComparisonNotes(fetchedNotes)
})
}}
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) {
console.error(`Failed to fetch note ${id}`)
return null
}
const data = await res.json()
if (data.success && data.data) {
return data.data
}
return null
} catch (error) {
console.error(`Error fetching note ${id}:`, error)
return null
}
}))
actions.setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
}}
/>
)}
{/* Dialog Toolbar - inline for now */}
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={handleSaveAndClose} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={actions.handleImageUpload}
/>
</div>
{/* ── AI Copilot Side Panel ── */}
{state.aiOpen && (
<ContextualAIChat
onClose={() => actions.setAiOpen(false)}
noteTitle={state.title}
noteContent={state.content}
noteImages={state.allImages}
noteId={note.id}
onApplyToNote={(newContent: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(newContent)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
if (state.previousContentForCopilot !== null) {
actions.setContent(state.previousContentForCopilot)
}
actions.setPreviousContentForCopilot(null)
} : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
/>
)}
</DialogContent>
{/* Reminder Dialog */}
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}
currentReminder={state.currentReminder}
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
{/* Link Dialog */}
<Dialog open={state.showLinkDialog} onOpenChange={actions.setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={state.linkUrl}
onChange={(e) => actions.setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
actions.handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => actions.setShowLinkDialog(false)}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleAddLink}>
{t('general.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reformulation Modal */}
{state.reformulationModal && (
<Dialog open={!!state.reformulationModal} onOpenChange={() => actions.setReformulationModal(null)}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div>
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
{state.reformulationModal.originalText}
</div>
</div>
<div>
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
{t('ai.reformulated')} ({state.reformulationModal.option})
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
{state.reformulationModal.reformulatedText}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => actions.setReformulationModal(null)}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleApplyRefactor}>
{t('general.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Comparison Modal */}
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={!!state.comparisonNotes}
onClose={() => actions.setComparisonNotes([])}
notes={state.comparisonNotes}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
}}
/>
)}
{/* Fusion Modal */}
{state.fusionNotes && state.fusionNotes.length > 0 && (
<FusionModal
isOpen={!!state.fusionNotes}
onClose={() => actions.setFusionNotes([])}
notes={state.fusionNotes}
onConfirmFusion={async ({ title, content }: { title: string; content: string }, options: { keepAllTags: boolean; archiveOriginals: boolean }) => {
// Save current first
await actions.handleSave()
// Use createNote directly since handleMakeCopy doesn't handle fusion
const { createNote } = 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
})
// Archive original notes if option is selected
if (options.archiveOriginals) {
const { updateNote } = await import('@/app/actions/notes')
for (const fusionNote of state.fusionNotes) {
if (fusionNote.id) {
await updateNote(fusionNote.id, { isArchived: true })
}
}
}
toast.success(t('toast.notesFusionSuccess'))
onClose()
}}
/>
)}
</Dialog>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { EditorImages } from '@/components/editor-images'
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 { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
import { format } from 'date-fns'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
return (
<>
{/* ── outer container ── */}
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
{/* BODY — max-w-4xl, px-12, py-16 */}
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
{/* Breadcrumb + Title block */}
<div className="space-y-4">
{/* Breadcrumb: Notebook Date */}
<div className="flex items-center gap-3 text-[12px] text-foreground/50 uppercase tracking-[.25em] font-bold">
{notebookName && <span>{notebookName}</span>}
{notebookName && <span></span>}
<span suppressHydrationWarning>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
</span>
</div>
{/* Title */}
<NoteTitleBlock />
</div>
{/* Hero image — show first note image if present */}
{state.allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={state.allImages[0]}
alt={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full pb-32">
<NoteContentArea />
</div>
</div>
</div>
{/* ── Side panel: AI Chat ── */}
{state.aiOpen && (
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
<ContextualAIChat
onClose={() => actions.setAiOpen(false)}
noteTitle={state.title}
noteContent={state.content}
noteImages={state.allImages}
noteId={note.id}
onApplyToNote={(nc: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(nc)
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
onGenerateTitle={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
}}
/>
</div>
)}
{/* ── Side panel: Document Info ── */}
{state.infoOpen && (
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
<NoteDocumentInfoPanel
note={note}
content={state.content}
onClose={() => actions.setInfoOpen(false)}
onNoteRestored={(r: Note) => { actions.setContent(r.content || ''); actions.setTitle(r.title || ''); actions.setIsDirty(false) }}
/>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}
currentReminder={state.currentReminder}
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
</>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
import { GhostTags } from '@/components/ghost-tags'
import { EditorImages } from '@/components/editor-images'
import { TitleSuggestions } from '@/components/title-suggestions'
import { NoteTypeSelector } from '@/components/note-type-selector'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut
} from 'lucide-react'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { format } from 'date-fns'
interface NoteEditorToolbarProps {
mode: 'fullPage' | 'dialog'
onClose: () => void
}
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
{/* Left: back */}
<button
onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
{/* Right: status + type + AI + Info */}
<div className="flex items-center gap-4">
{/* Save status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
</span>
{/* Note type */}
<NoteTypeSelector
value={state.noteType}
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
compact
/>
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
<span>{state.showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
</button>
)}
{/* AI — rounded-full, exact prototype style */}
<button
aria-label="Ouvrir l'assistant IA"
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
<span>AI Assistant</span>
</button>
{/* Info — rounded-full */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Info size={16} />
<span>Document Info</span>
</button>
{/* Save button */}
{!readOnly && (
<button
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
<span>{state.isSaving ? 'Saving…' : 'Save'}</span>
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={async () => {
try {
await deleteNote(note.id)
triggerRefresh()
toast.success('Note supprimée.')
onClose()
} catch { toast.error('Impossible de supprimer.') }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer la note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}
// Dialog toolbar
return (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
{state.noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* AI Copilot */}
{state.noteType !== 'checklist' && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
</Button>
)}
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{(['small', 'medium', 'large'] as const).map((s) => (
<Button key={s} variant="ghost" size="sm"
onClick={() => actions.setSize(s)}
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button key={colorName}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
</div>
)}
</div>
<div className="flex gap-2">
{readOnly ? (
<>
<Button
variant="default"
onClick={actions.handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
{t('notes.makeCopy')}
</Button>
<Button
variant="ghost"
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)
toast.success(t('notes.leftShare') || 'Share removed')
triggerRefresh()
onClose()
} catch {
toast.error(t('general.error'))
}
}}
>
<LogOut className="h-4 w-4" />
{t('notes.leaveShare')}
</Button>
<Button variant="ghost" onClick={onClose}>
{t('general.close')}
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleSave} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { LabelBadge } from '../label-badge'
import { GhostTags } from '../ghost-tags'
import { cn } from '@/lib/utils'
export function NoteMetadataSection() {
const { state, actions, readOnly } = useNoteEditorContext()
return (
<div className="space-y-4">
{/* Labels */}
{state.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{state.labels.map((label) => (
<LabelBadge
key={label}
label={label}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
</div>
)}
{/* Ghost Tags - only show in dialog mode */}
{!readOnly && state.noteType !== 'richtext' && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
{/* Color indicator */}
<div className="flex items-center gap-2">
<span className="text-xs text-foreground/50">Color:</span>
<div className={cn('w-4 h-4 rounded-full', state.colorClasses?.bg || 'bg-gray-100')} />
</div>
{/* Size indicator */}
<div className="flex items-center gap-2">
<span className="text-xs text-foreground/50">Size:</span>
<span className="text-xs capitalize">{state.size}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { TitleSuggestions } from '@/components/title-suggestions'
import { Loader2, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
export function NoteTitleBlock() {
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
const { t } = useLanguage()
if (fullPage) {
return (
<div className="space-y-4">
{/* Title — auto-resizing textarea to prevent overflow */}
<div className="group relative">
<textarea
dir="auto"
rows={1}
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={state.title}
onChange={(e) => { actions.setTitle(e.target.value) }}
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
!readOnly && 'pr-12'
)}
/>
{/* AI title generation — always visible on hover */}
{!readOnly && (
<button
type="button"
onClick={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
title="Générer un titre automatique avec l'IA"
>
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{/* Auto title suggestions */}
{!state.title && !state.dismissedTitleSuggestions && state.titleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={state.titleSuggestions}
onSelect={(s: string) => { actions.setTitle(s); actions.setDismissedTitleSuggestions(true) }}
onDismiss={() => actions.setDismissedTitleSuggestions(true)}
/>
)}
</div>
)
}
// Dialog mode title block
return (
<div className="relative">
<input
dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={state.title}
onChange={(e) => actions.setTitle(e.target.value)}
disabled={readOnly}
className={cn(
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
readOnly && "cursor-default"
)}
/>
<button
onClick={actions.handleGenerateTitles}
disabled={state.isGeneratingTitles || readOnly}
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
>
{state.isGeneratingTitles ? (
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
// State interface - all local state from NoteEditor
export interface NoteEditorState {
// Core content state
title: string
content: string
checkItems: CheckItem[]
labels: string[]
images: string[]
links: LinkMetadata[]
newLabel: string
color: NoteColor
size: NoteSize
noteType: NoteType
// UI state
showMarkdownPreview: boolean
removedImageUrls: string[]
isSaving: boolean
isDirty: boolean
// AI state
isProcessingAI: boolean
aiOpen: boolean
infoOpen: boolean
isGeneratingTitles: boolean
titleSuggestions: TitleSuggestion[]
dismissedTitleSuggestions: boolean
isReformulating: boolean
reformulationModal: {
originalText: string
reformulatedText: string
option: string
} | null
previousContentForCopilot: string | null
// Reminder state
showReminderDialog: boolean
currentReminder: Date | null
// Link dialog state
showLinkDialog: boolean
linkUrl: string
// Memory Echo Connections
comparisonNotes: Array<Partial<Note>>
fusionNotes: Array<Partial<Note>>
// Ghost tags
dismissedTags: string[]
// Tag suggestions (from auto-tagging)
filteredSuggestions: TagSuggestion[]
isAnalyzingSuggestions: boolean
// Context-derived values
isMarkdown: boolean
allImages: string[]
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
}
// Actions interface - all handlers from NoteEditor
export interface NoteEditorActions {
// Title actions
setTitle: (title: string) => void
setDismissedTitleSuggestions: (dismissed: boolean) => void
// Content actions
setContent: (content: string) => void
// CheckItems actions
setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void
handleUpdateCheckItem: (id: string, text: string) => void
handleAddCheckItem: () => void
handleRemoveCheckItem: (id: string) => void
// Labels actions
setLabels: (labels: string[]) => void
handleSelectGhostTag: (tag: string) => void
handleDismissGhostTag: (tag: string) => void
handleRemoveLabel: (label: string) => void
// Images actions
setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void
uploadImageFile: (file: File) => Promise<string>
// Links actions
setLinks: (links: LinkMetadata[]) => void
handleAddLink: () => Promise<void>
handleRemoveLink: (index: number) => void
// Note properties
setNoteType: (type: NoteType) => void
setShowMarkdownPreview: (show: boolean) => void
setColor: (color: NoteColor) => void
setSize: (size: NoteSize) => void
// Reminder actions
setShowReminderDialog: (show: boolean) => void
setCurrentReminder: (date: Date | null) => void
handleReminderSave: (date: Date) => Promise<void>
handleRemoveReminder: () => Promise<void>
// Link dialog
setShowLinkDialog: (show: boolean) => void
setLinkUrl: (url: string) => void
// Title suggestions
handleGenerateTitles: () => Promise<void>
handleSelectTitle: (title: string) => void
// Reformulation
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
handleApplyRefactor: () => void
// AI Direct handlers
handleClarifyDirect: () => Promise<void>
handleShortenDirect: () => Promise<void>
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
// Save actions
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleMakeCopy: () => Promise<void>
// Memory Echo
setComparisonNotes: (notes: Array<Partial<Note>>) => void
setFusionNotes: (notes: Array<Partial<Note>>) => void
// Modal states
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
// State setters
setIsDirty: (dirty: boolean) => void
setAiOpen: (open: boolean) => void
setInfoOpen: (open: boolean) => void
setIsProcessingAI: (processing: boolean) => void
setIsGeneratingTitles: (generating: boolean) => void
setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void
}
// Context value - combines state + actions + note reference
export interface NoteEditorContextValue {
// The current note (external source of truth)
note: Note
// Read-only flag
readOnly: boolean
// FullPage flag
fullPage: boolean
// All state
state: NoteEditorState
// All actions
actions: NoteEditorActions
// Computed values from contexts
notebooks: Array<{ id: string; name: string }>
globalLabels: Array<{ name: string }>
// Refs
fileInputRef: React.RefObject<HTMLInputElement | null>
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}