'use client' import { useState, useEffect, useRef, useCallback, useTransition, useMemo } from 'react' import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { LabelBadge } from '@/components/label-badge' import { EditorConnectionsSection } from '@/components/editor-connections-section' import { FusionModal } from '@/components/fusion-modal' import { ComparisonModal } from '@/components/comparison-modal' import { NoteTypeSelector } from '@/components/note-type-selector' import { RichTextEditor } from '@/components/rich-text-editor' import { useLanguage } from '@/lib/i18n' import { cn, extractImagesFromHTML } from '@/lib/utils' import { updateNote, toggleArchive, deleteNote, createNote, commitNoteHistory, } from '@/app/actions/notes' import { fetchLinkMetadata } from '@/app/actions/scrape' import { Pin, Palette, Archive, ArchiveRestore, Trash2, ImageIcon, Link as LinkIcon, X, Plus, CheckSquare, Eye, Sparkles, Loader2, Check, RotateCcw, History, GitCommitHorizontal, } from 'lucide-react' import { toast } from 'sonner' import { MarkdownContent } from '@/components/markdown-content' import { EditorImages } from '@/components/editor-images' import { useAutoTagging } from '@/hooks/use-auto-tagging' import { GhostTags } from '@/components/ghost-tags' import { useTitleSuggestions } from '@/hooks/use-title-suggestions' import { TitleSuggestions } from '@/components/title-suggestions' import { useLabels } from '@/context/LabelContext' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useNotebooks } from '@/context/notebooks-context' import { ContextualAIChat } from '@/components/contextual-ai-chat' import { formatDistanceToNow } from 'date-fns' import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' import { useSession } from 'next-auth/react' import { getAISettings } from '@/app/actions/ai-settings' interface NoteInlineEditorProps { note: Note onDelete?: (noteId: string) => void onArchive?: (noteId: string) => void onChange?: (noteId: string, fields: Partial) => void onOpenHistory?: (note: Note) => void onEnableHistory?: (noteId: string) => Promise noteHistoryMode?: 'manual' | 'auto' colorKey: NoteColor /** If true and the note is a Markdown note, open directly in preview mode */ defaultPreviewMode?: boolean } function getDateLocale(language: string) { if (language === 'fr') return fr; if (language === 'fa') return require('date-fns/locale').faIR; return enUS; } /** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */ async function saveInline( id: string, data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean; type?: NoteType } ) { await fetch(`/api/notes/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) } export function NoteInlineEditor({ note, onDelete, onArchive, onChange, onOpenHistory, onEnableHistory, noteHistoryMode = 'manual', colorKey, defaultPreviewMode = false, }: NoteInlineEditorProps) { const { t, language } = useLanguage() const { data: session } = useSession() const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true) const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true) useEffect(() => { if (session?.user?.id) { const userId = session.user.id import('@/app/actions/ai-settings').then(({ getAISettings }) => { getAISettings(userId).then(settings => { setAiAssistantEnabled(settings.paragraphRefactor !== false) setAutoLabelingEnabled(settings.autoLabeling !== false) }).catch(err => console.error("Failed to fetch AI settings", err)) }) } }, [session?.user?.id]) const { labels: globalLabels, addLabel } = useLabels() const [, startTransition] = useTransition() const { triggerRefresh } = useNoteRefresh() // ── Local edit state ────────────────────────────────────────────────────── const [title, setTitle] = useState(note.title || '') const [content, setContent] = useState(note.content || '') const [checkItems, setCheckItems] = useState(note.checkItems || []) const [noteType, setNoteType] = useState(note.type) const isMarkdown = noteType === 'markdown' const allImages = useMemo(() => { const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : []; return Array.from(new Set([...(note.images || []), ...extracted])); }, [note.images, content, noteType]); const [showMarkdownPreview, setShowMarkdownPreview] = useState( defaultPreviewMode && (note.isMarkdown || false) ) const [isDirty, setIsDirty] = useState(false) const [isSaving, setIsSaving] = useState(false) const [dismissedTags, setDismissedTags] = useState([]) const [fusionNotes, setFusionNotes] = useState>>([]) const [comparisonNotes, setComparisonNotes] = useState>>([]) const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) } const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) } const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) } // Link dialog const [linkUrl, setLinkUrl] = useState('') const [showLinkInput, setShowLinkInput] = useState(false) const [isAddingLink, setIsAddingLink] = useState(false) // AI side panel const [aiOpen, setAiOpen] = useState(false) const [isProcessingAI, setIsProcessingAI] = useState(false) // Undo after AI copilot applies content const [previousContent, setPreviousContent] = useState(null) // Notebooks list (for copilot chat scope) const { notebooks } = useNotebooks() const fileInputRef = useRef(null) const saveTimerRef = useRef | undefined>(undefined) const pendingRef = useRef({ title, content, checkItems, isMarkdown, noteType }) const noteIdRef = useRef(note.id) // Title suggestions const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({ content: noteType !== 'checklist' ? content : '', enabled: noteType !== 'checklist' && !title }) // Keep pending ref in sync for unmount save useEffect(() => { pendingRef.current = { title, content, checkItems, isMarkdown, noteType } }, [title, content, checkItems, isMarkdown, noteType]) // ── Sync when selected note switches ───────────────────────────────────── useEffect(() => { // Flush unsaved changes for the PREVIOUS note before switching if (isDirty && noteIdRef.current !== note.id) { const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current saveInline(noteIdRef.current, { title: t.trim() || null, content: c, checkItems: nt === 'checklist' ? ci : undefined, type: nt, isMarkdown: nt === 'markdown', }).catch(() => {}) } noteIdRef.current = note.id setTitle(note.title || '') setContent(note.content || '') setCheckItems(note.checkItems || []) setNoteType(note.type) setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown')) setIsDirty(false) setDismissedTitleSuggestions(false) clearTimeout(saveTimerRef.current) // eslint-disable-next-line react-hooks/exhaustive-deps }, [note.id]) // ── Auto-save (1.5 s debounce, skipContentTimestamp) ───────────────────── const scheduleSave = useCallback(() => { setIsDirty(true) clearTimeout(saveTimerRef.current) saveTimerRef.current = setTimeout(async () => { const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current setIsSaving(true) try { await saveInline(noteIdRef.current, { title: t.trim() || null, content: c, checkItems: nt === 'checklist' ? ci : undefined, type: nt, isMarkdown: nt === 'markdown', }) setIsDirty(false) } catch { // silent — retry on next keystroke } finally { setIsSaving(false) } }, 1500) }, [noteType]) // Flush on unmount useEffect(() => { return () => { clearTimeout(saveTimerRef.current) const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current saveInline(noteIdRef.current, { title: t.trim() || null, content: c, checkItems: nt === 'checklist' ? ci : undefined, type: nt, isMarkdown: nt === 'markdown', }).catch(() => {}) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ── Auto-tagging ────────────────────────────────────────────────────────── const { suggestions, isAnalyzing } = useAutoTagging({ content: noteType !== 'checklist' ? content : '', notebookId: note.notebookId, enabled: noteType !== 'checklist' && autoLabelingEnabled, }) const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase()) const filteredSuggestions = suggestions.filter( (s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase()) ) const handleSelectGhostTag = async (tag: string) => { const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase()) if (!exists) { const newLabels = [...(note.labels || []), tag] // Optimistic UI — update sidebar immediately, no page refresh needed onChange?.(note.id, { labels: newLabels }) await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true }) const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase()) if (!globalExists) { try { await addLabel(tag) } catch {} } toast.success(t('ai.tagAdded', { tag })) } } const fetchNotesByIds = async (noteIds: string[]) => { const fetched = 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 : null } catch { return null } })) return fetched.filter((n: any) => n !== null) as Array> } const handleMergeNotes = async (noteIds: string[]) => { setFusionNotes(await fetchNotesByIds(noteIds)) } const handleCompareNotes = async (noteIds: string[]) => { setComparisonNotes(await fetchNotesByIds(noteIds)) } const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => { await createNote({ title, content, labels: options.keepAllTags ? [...new Set(fusionNotes.flatMap(n => n.labels || []))] : fusionNotes[0].labels || [], color: fusionNotes[0].color, type: 'markdown', isMarkdown: true, autoGenerated: true, aiProvider: 'fusion', notebookId: fusionNotes[0].notebookId ?? undefined }) if (options.archiveOriginals) { for (const n of fusionNotes) { if (n.id) await updateNote(n.id, { isArchived: true }) } } toast.success(t('toast.notesFusionSuccess')) setFusionNotes([]) triggerRefresh() } // ── Quick actions (pin, archive, color, delete) ─────────────────────────── const handleTogglePin = () => { const prev = note.isPinned startTransition(async () => { onChange?.(note.id, { isPinned: !prev }) try { await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true }) toast.success(prev ? t('notes.unpinned') : t('notes.pinned') ) } catch { onChange?.(note.id, { isPinned: prev }) toast.error(t('general.error')) } }) } const handleToggleArchive = () => { startTransition(async () => { onArchive?.(note.id) try { await toggleArchive(note.id, !note.isArchived) triggerRefresh() } catch { // Cannot easily revert since onArchive removes from list toast.error(t('general.error')) } }) } const handleColorChange = (color: string) => { const prev = color startTransition(async () => { onChange?.(note.id, { color }) try { await updateNote(note.id, { color }, { skipRevalidation: true }) } catch { onChange?.(note.id, { color: prev }) toast.error(t('general.error')) } }) } const handleDelete = () => { if (!confirm(t('notes.confirmDelete'))) return startTransition(async () => { await deleteNote(note.id) onDelete?.(note.id) triggerRefresh() }) } // ── Image upload ────────────────────────────────────────────────────────── const handleImageUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files) return for (const file of Array.from(files)) { try { const url = await uploadImageFile(file) const newImages = [...(note.images || []), url] onChange?.(note.id, { images: newImages }) await updateNote(note.id, { images: newImages }) } catch { toast.error(t('notes.uploadFailed', { filename: file.name })) } } if (fileInputRef.current) fileInputRef.current.value = '' } const uploadImageFile = async (file: File) => { const formData = new FormData() formData.append('file', file) const res = await fetch('/api/upload', { method: 'POST', body: formData }) if (!res.ok) throw new Error('Upload failed') const data = await res.json() return data.url } // 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) const newImages = [...(note.images || []), url] onChange?.(note.id, { images: newImages }) await updateNote(note.id, { images: newImages }) } catch { toast.error(t('notes.uploadFailed', { filename: 'pasted image' })) } } } } document.addEventListener('paste', handlePaste, { capture: true }) return () => document.removeEventListener('paste', handlePaste, { capture: true } as any) }, [note.id, note.images, onChange, t]) const handleRemoveImage = async (index: number) => { const newImages = (note.images || []).filter((_, i) => i !== index) onChange?.(note.id, { images: newImages }) await updateNote(note.id, { images: newImages }) } // ── Link ────────────────────────────────────────────────────────────────── const handleAddLink = async () => { if (!linkUrl) return setIsAddingLink(true) try { const metadata = await fetchLinkMetadata(linkUrl) const newLink = metadata || { url: linkUrl, title: linkUrl } const newLinks = [...(note.links || []), newLink] onChange?.(note.id, { links: newLinks }) await updateNote(note.id, { links: newLinks }) toast.success(t('notes.linkAdded')) } catch { toast.error(t('notes.linkAddFailed')) } finally { setLinkUrl('') setShowLinkInput(false) setIsAddingLink(false) } } const handleRemoveLink = async (index: number) => { const newLinks = (note.links || []).filter((_, i) => i !== index) onChange?.(note.id, { links: newLinks }) await updateNote(note.id, { links: newLinks }) } // ── Checklist helpers ───────────────────────────────────────────────────── const handleToggleCheckItem = (id: string) => { const updated = checkItems.map((ci) => ci.id === id ? { ...ci, checked: !ci.checked } : ci ) setCheckItems(updated) scheduleSave() } const handleUpdateCheckText = (id: string, text: string) => { const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci)) setCheckItems(updated) scheduleSave() } const handleAddCheckItem = () => { const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }] setCheckItems(updated) scheduleSave() } const handleRemoveCheckItem = (id: string) => { const updated = checkItems.filter((ci) => ci.id !== id) setCheckItems(updated) scheduleSave() } const dateLocale = getDateLocale(language) return (
{/* ── Toolbar ───────────────────────────────────────────────── */}
{/* Left group: content tools */}
{ const oldType = noteType // markdown → richtext: convert content to HTML first if (oldType === 'markdown' && newType === 'richtext') { try { const res = await fetch('/api/ai/convert-markdown', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }) if (res.ok) { const { html } = await res.json() setNoteType('richtext') setShowMarkdownPreview(false) setContent(html) saveInline(note.id, { type: 'richtext', isMarkdown: false, content: html, }).catch(() => {}) onChange?.(note.id, { type: 'richtext', isMarkdown: false, content: html }) toast.success(t('notes.convertedToRichText') || 'Converted to rich text') return } } catch {} // Conversion failed — abort the type change toast.error(t('notes.conversionFailed') || 'Conversion failed, staying in Markdown') return } setNoteType(newType) if (newType === 'markdown') setShowMarkdownPreview(true) else setShowMarkdownPreview(false) // Persist both type and isMarkdown immediately saveInline(note.id, { type: newType, isMarkdown: newType === 'markdown', }).catch(() => {}) onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' }) }} compact /> {noteType === 'markdown' && ( )} {noteType !== 'checklist' && aiAssistantEnabled && ( )} {previousContent !== null && ( )}
{/* Right group: meta actions + save indicator */}
{note.historyEnabled && noteHistoryMode === 'manual' && ( )} {isSaving ? ( <> {t('notes.saving')} ) : isDirty ? ( <> {t('notes.dirtyStatus')} ) : ( <> {t('notes.savedStatus')} )}
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
{note.isArchived ? <>{t('notes.unarchive')} : <>{t('notes.archive')}} {onOpenHistory && ( { if (note.historyEnabled) { onOpenHistory(note) } else if (onEnableHistory) { onEnableHistory(note.id).then(() => onOpenHistory({ ...note, historyEnabled: true })) } }} > {note.historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")} )} {t('notes.delete')}
{/* ── Link input bar (inline) ───────────────────────────────────────── */} {showLinkInput && (
setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }} autoFocus />
)} {/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */} {((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
{/* Existing labels */} {(note.labels ?? []).map((label) => ( ))} {/* AI-suggested tags inline with labels */} setDismissedTags((p) => [...p, tag])} />
)} {/* ── Scrollable editing area ── */}
{/* Title */}
{ changeTitle(e.target.value); scheduleSave() }} /> {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && ( )}
{/* Title Suggestions Dropdown / Inline list */} {!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
{ changeTitle(selectedTitle); scheduleSave() }} onDismiss={() => setDismissedTitleSuggestions(true)} />
)} {/* Images */} {Array.isArray(note.images) && note.images.length > 0 && (
)} {/* Link previews */} {Array.isArray(note.links) && note.links.length > 0 && (
{note.links.map((link, idx) => (
{link.imageUrl && (
)}

{link.title || link.url}

{link.description &&

{link.description}

} {(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
))}
)} {/* ── Text / Checklist content ───────────────────────────────────── */}
{noteType === 'richtext' ? ( ) : noteType === 'text' || noteType === 'markdown' ? (
{showMarkdownPreview && isMarkdown ? (
) : (