Files
Momento/memento-note/components/note-editor.tsx

1444 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType } from '@/lib/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, ChevronRight, Info, Check, Loader2 } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { format } from 'date-fns'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { MarkdownSlashCommands } from './markdown-slash-commands'
import { NoteDocumentInfoPanel } from './note-document-info-panel'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn, extractImagesFromHTML } from '@/lib/utils'
import { toast } from 'sonner'
import { MarkdownContent } from './markdown-content'
import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
import { ReminderDialog } from './reminder-dialog'
import { EditorImages } from './editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { TitleSuggestions } from './title-suggestions'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import { EditorConnectionsSection } from './editor-connections-section'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { ContextualAIChat } from './contextual-ai-chat'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useNotebooks } from '@/context/notebooks-context'
import { NoteSize } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
fullPage?: boolean
}
export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }: NoteEditorProps) {
const { data: session } = useSession()
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])
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId } = useLabels()
const { triggerRefresh } = useNoteRefresh()
const { t } = useLanguage()
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')
const fileInputRef = useRef<HTMLInputElement>(null)
const prevNoteRef = useRef(note)
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 - use local state for live suggestions as user types
const { suggestions, isAnalyzing } = 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 for ActionBar
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 textareaRef = useRef<HTMLTextAreaElement>(null)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Notebooks (for copilot chat scope)
const { notebooks } = useNotebooks()
// 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[]>([])
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const handleSelectGhostTag = async (tag: string) => {
// Case-insensitive check
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setLabels(prev => [...prev, tag])
// Create the label globally if it doesn't exist
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])
}
// 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 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])
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 () => {
// Combine content and link metadata for AI
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
.trim()
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]);
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') => {
// Get selected text or full content
const selectedText = window.getSelection()?.toString()
if (!selectedText && (!content || content.trim().length === 0)) {
toast.error(t('ai.reformulationNoText'))
return
}
// If selection is too short, use full content instead
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()
// Show reformulation modal
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)
}
}
// Simplified AI handlers for ActionBar (direct content update)
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'))
// Set the transformed markdown content and enable markdown mode
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
// If selected text exists, replace it
const selectedText = window.getSelection()?.toString()
if (selectedText) {
// For now, replace full content (TODO: improve to replace selection only)
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,
})
// Clean up removed image files from disk (best-effort, don't block save)
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
// Refresh global labels to reflect any deletions (orphans)
await refreshLabels()
// Refresh the notes list
triggerRefresh()
onClose()
} 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 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()
onClose()
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
// ── fullPage mode: editorial layout (fidèle au prototype) ──
if (fullPage) {
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
return (
<>
{/* ── outer container: white like prototype ── */}
<div className="h-full flex 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 — px-12 py-8, bg-white/90, rounded-full buttons */}
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-zinc-950/90 backdrop-blur-sm z-40 border-b border-black/10 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">
{isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
: 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={noteType}
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
compact
/>
{/* AI — rounded-full, exact prototype style */}
<button
onClick={() => { setAiOpen(v => !v); 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',
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
onClick={() => { setInfoOpen(v => !v); 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',
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>
</div>
</div>
{/* 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 && <ChevronRight size={10} />}
<span suppressHydrationWarning>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
</span>
</div>
{/* Title — editable input styled as h1 */}
<div className="group relative">
<input
dir="auto"
type="text"
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={title}
onChange={(e) => { setTitle(e.target.value); setIsDirty(true); setDismissedTitleSuggestions(true) }}
disabled={readOnly}
className={cn(
'w-full text-5xl md:text-6xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'placeholder:text-foreground/20',
!readOnly && 'pr-14'
)}
/>
{/* AI title generation — shown when title is empty */}
{!title && !readOnly && (
<button
type="button"
onClick={async () => {
const plain = content.replace(/<[^>]+>/g, ' ').trim()
if (plain.split(/\s+/).filter(Boolean).length < 3) return
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.title || data.suggestedTitle || (data.suggestions?.[0]?.title ?? '')
if (s) { setTitle(s); setIsDirty(true) }
}
} catch {} finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
title="Generate AI title"
>
{isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{/* Auto title suggestions */}
{!title && !dismissedTitleSuggestions && autoTitleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={autoTitleSuggestions}
onSelect={(s) => { setTitle(s); setDismissedTitleSuggestions(true); setIsDirty(true) }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
)}
</div>
{/* Hero image — show first note image if present */}
{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={allImages[0]}
alt={title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Content area — max-w-2xl, like prototype */}
<div className="max-w-2xl mx-auto space-y-8 pb-32">
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
className="min-h-[300px] text-lg font-light leading-relaxed text-foreground/80"
onImageUpload={uploadImageFile}
/>
) : noteType === 'markdown' && showMarkdownPreview ? (
<div
className="min-h-[300px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed"
onClick={() => !readOnly && setShowMarkdownPreview(false)}
>
<MarkdownContent content={content} />
{!readOnly && (
<p className="text-[11px] text-foreground/30 mt-6 select-none not-prose">
Click to edit
</p>
)}
</div>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="relative">
<textarea
ref={textareaRef}
dir="auto"
placeholder={t('notes.takeNote') || "Type '/' for commands…"}
value={content}
onFocus={() => setShowMarkdownPreview(false)}
onChange={(e) => { setContent(e.target.value); setIsDirty(true) }}
disabled={readOnly}
className="w-full min-h-[60vh] border-0 outline-none px-0 bg-transparent text-lg leading-relaxed font-light resize-none placeholder:text-foreground/20 text-foreground/80"
/>
{noteType === 'markdown' && content && !readOnly && (
<button
type="button"
onClick={() => setShowMarkdownPreview(true)}
className="mt-2 text-[11px] text-foreground/40 hover:text-foreground flex items-center gap-1.5 transition-colors"
>
<Eye className="h-3 w-3" /> Preview Markdown
</button>
)}
{!readOnly && (
<MarkdownSlashCommands
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
value={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
/>
)}
</div>
) : null}
</div>
</div>
</div>
{/* ── Side panel: AI Chat (prototype style: w-[400px], border-l, bg-white) ── */}
{aiOpen && (
<div className="w-[400px] border-l border-black/10 dark:border-white/10 bg-white dark:bg-zinc-950 shadow-2xl flex flex-col z-50 shrink-0">
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(nc) => {
setPreviousContentForCopilot(content)
setContent(nc)
setIsDirty(true)
if (noteType === 'markdown') setShowMarkdownPreview(true)
}}
onUndoLastAction={previousContentForCopilot !== null ? () => { setContent(previousContentForCopilot!); setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={previousContentForCopilot !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
/>
</div>
)}
{/* ── Side panel: Document Info ── */}
{infoOpen && (
<div className="w-[400px] border-l border-black/10 dark:border-white/10 bg-white dark:bg-zinc-950 shadow-2xl flex flex-col z-50 shrink-0">
<NoteDocumentInfoPanel
note={note}
content={content}
onClose={() => setInfoOpen(false)}
onNoteRestored={(r) => { setContent(r.content || ''); setTitle(r.title || ''); setIsDirty(false) }}
/>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
/>
</>
)
}
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',
colorClasses.bg
)}
>
<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 */}
<div className="relative">
<Input
dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={readOnly}
className={cn(
"text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
readOnly && "cursor-default"
)}
/>
<button
onClick={handleGenerateTitles}
disabled={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={isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
>
{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>
{/* Title Suggestions */}
{!readOnly && titleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={handleSelectTitle}
onDismiss={() => setTitleSuggestions([])}
/>
)}
{/* Images */}
<EditorImages images={images} onRemove={handleRemoveImage} />
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2">
{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={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Content or Checklist */}
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="space-y-2">
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || t('notes.noContent')}
className="min-h-[200px] p-3 rounded-md border border-border/40 bg-muted/20"
/>
) : (
<Textarea
dir="auto"
placeholder={isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
value={content}
onChange={(e) => 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={filteredSuggestions}
addedTags={labels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
</div>
) : (
<div className="space-y-2">
{checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => 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={() => handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div>
)}
{/* Labels */}
{labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<LabelBadge
key={label}
label={label}
onRemove={() => handleRemoveLabel(label)}
/>
))}
</div>
)}
{/* Memory Echo Connections Section */}
{!readOnly && (
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(noteId) => {
// Close current editor and reload page with the selected note
onClose()
window.location.href = `/?note=${noteId}`
}}
onCompareNotes={(noteIds) => {
// Note: noteIds already includes current note
// Fetch all notes for comparison
Promise.all(noteIds.map(async (id) => {
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 => {
setComparisonNotes(fetchedNotes)
})
}}
onMergeNotes={async (noteIds) => {
// Fetch notes for fusion (noteIds already includes current note)
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
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
}
}))
// Filter out nulls
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
}}
/>
)}
{/* Toolbar */}
<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', currentReminder && 'text-primary')}
onClick={() => 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={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* AI Copilot */}
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!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={() => setSize(s)}
className={cn('justify-start capitalize', 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,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => setColor(colorName)} title={colorName} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager existingLabels={labels} notebookId={note.notebookId} onUpdate={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={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={handleSave} disabled={isSaving}>
{isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>
)}
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageUpload}
/>
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContentForCopilot(content)
setContent(newContent)
}}
onUndoLastAction={previousContentForCopilot !== null ? () => {
setContent(previousContentForCopilot)
setPreviousContentForCopilot(null)
} : undefined}
lastActionApplied={previousContentForCopilot !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
/>
)}
</DialogContent>
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
/>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
{t('general.cancel')}
</Button>
<Button onClick={handleAddLink}>
{t('general.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reformulation Modal */}
{reformulationModal && (
<Dialog open={!!reformulationModal} onOpenChange={() => 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">
{reformulationModal.originalText}
</div>
</div>
<div>
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
{t('ai.reformulated')} ({reformulationModal.option})
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
{reformulationModal.reformulatedText}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setReformulationModal(null)}>
{t('general.cancel')}
</Button>
<Button onClick={handleApplyRefactor}>
{t('general.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Comparison Modal */}
{comparisonNotes && comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={!!comparisonNotes}
onClose={() => setComparisonNotes([])}
notes={comparisonNotes}
onOpenNote={(noteId) => {
// Close current editor and open the selected note
onClose()
// Trigger navigation to the note
window.location.href = `/?note=${noteId}`
}}
/>
)}
{/* Fusion Modal */}
{fusionNotes && fusionNotes.length > 0 && (
<FusionModal
isOpen={!!fusionNotes}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
// Create the fused note
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, // AI generates markdown content
autoGenerated: true, // Mark as AI-generated fused note
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined // Keep the notebook from the first note, convert null to undefined
})
// Archive original notes if option is selected
if (options.archiveOriginals) {
for (const note of fusionNotes) {
if (note.id) {
await updateNote(note.id, { isArchived: true })
}
}
}
toast.success(t('toast.notesFusionSuccess'))
triggerRefresh()
onClose()
}}
/>
)}
</Dialog>
)
}