Files
Momento/memento-note/components/note-editor.tsx
sepehr 153c921960
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Replaced ~100+ hardcoded French and English text strings across 30+ components
with proper i18n t() calls. Added 57 new translation keys to all 15 locale files
(ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh).

Key changes:
- contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders)
- ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history)
- note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist)
- lab-skeleton.tsx: French loading text → t()
- admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed
- New AI chat component, agent cards, sidebar, settings panel i18n cleanup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:14:45 +02:00

1122 lines
41 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } 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 { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } 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
}
export function NoteEditor({ note, readOnly = false, onClose }: 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 [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const fileInputRef = useRef<HTMLInputElement>(null)
// 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: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && 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)
// 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 handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
}
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 handleGenerateTitles = async () => {
// Combine content and link metadata for AI
const fullContent = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
].join(' ').trim()
const wordCount = fullContent.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: fullContent }),
})
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)
setIsMarkdown(true)
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: note.type === 'text' ? content : '',
checkItems: note.type === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown,
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,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
size: size,
})
toast.success(t('notes.copySuccess'))
triggerRefresh()
onClose()
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
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',
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>
{/* AI Copilot Toggle Button next to title */}
{note.type === 'text' && !readOnly && aiAssistantEnabled && (
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors ml-2',
aiOpen && 'bg-primary/10 text-primary'
)}
onClick={() => setAiOpen(!aiOpen)}
title="Toggle AI Copilot"
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Assistant IA</span>
</Button>
)}
</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
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 */}
{note.type === 'text' ? (
<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
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', 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"
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"
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
{note.type === 'text' && (
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
)}
{/* Markdown preview toggle */}
{isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* AI Copilot */}
{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="Assistant IA">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Assistant IA</span>
</Button>
)}
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" 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" 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}
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>
)
}