Files
Momento/memento-note/components/note-inline-editor.tsx
sepehr b92f6384a4
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
fix: chat memory lost between messages + per-note history
Chat (AIChat floating widget): conversationId was never captured from
the API response, so every message created a new conversation with no
context. Now creates the conversation upfront before streaming (same
pattern as ChatContainer) so the ID persists across messages.

Note history: was stored globally in UserAISettings, so enabling
history on one note enabled it for ALL notes. Now each Note has its
own historyEnabled boolean field. The "Enable history" action only
affects the specific note. A migration adds the column with default
false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:18:46 +02:00

887 lines
36 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } 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 { useLanguage } from '@/lib/i18n'
import { cn } 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,
FileText,
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<Note>) => void
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
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 }
) {
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<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
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<string | null>(null)
// Notebooks list (for copilot chat scope)
const { notebooks } = useNotebooks()
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
// ── 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 } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
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 } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [note.type])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && 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<Partial<Note>>
}
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: 'text',
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<HTMLInputElement>) => {
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) => {
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)
return () => document.removeEventListener('paste', handlePaste)
}, [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 (
<div className="flex h-full w-full overflow-hidden">
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
{/* ── Toolbar ───────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
{/* Left group: content tools */}
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addImage') }
onClick={() => fileInputRef.current?.click()}>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addLink') }
onClick={() => setShowLinkInput(!showLinkInput)}>
<LinkIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => {
const nextIsMarkdown = !isMarkdown
setIsMarkdown(nextIsMarkdown)
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
}}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
{isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
<Eye className="h-4 w-4" />
</Button>
)}
{note.type === 'text' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title={t('ai.aiCopilot')}>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
</Button>
)}
{previousContent !== null && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
{note.historyEnabled && noteHistoryMode === 'manual' && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
title={t('notes.commitVersion')}
onClick={() => {
startTransition(async () => {
try {
await commitNoteHistory(note.id)
toast.success(t('notes.versionSaved'))
} catch {
toast.error(t('general.error'))
}
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
</Button>
)}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button" key={name}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => handleColorChange(name)} title={name} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => {
if (note.historyEnabled) {
onOpenHistory(note)
} else if (onEnableHistory) {
onEnableHistory(note.id).then(() => onOpenHistory(note))
}
}}
>
<History className="h-4 w-4 mr-2" />
{note.historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area ── */}
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/suggest-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title={t('ai.suggestTitle')}
>
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{Array.isArray(note.images) && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{Array.isArray(note.links) && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown')
: t('notes.takeNote')
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') }
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') }
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Memory Echo Connections Section ── */}
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(connNoteId) => {
window.open(`/?note=${connNoteId}`, '_blank')
}}
onCompareNotes={handleCompareNotes}
onMergeNotes={handleMergeNotes}
/>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={handleConfirmFusion}
/>
)}
{/* Comparison Modal */}
{comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={comparisonNotes.length > 0}
onClose={() => setComparisonNotes([])}
notes={comparisonNotes}
/>
)}
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={note.images || undefined}
onApplyToNote={(newContent) => {
setPreviousContent(content)
changeContent(newContent)
scheduleSave()
}}
onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
/>
)}
</div>
)
}