'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 } from 'lucide-react' import { updateNote, createNote } 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 { EditorConnectionsSection } from './editor-connections-section' import { ComparisonModal } from './comparison-modal' import { FusionModal } from './fusion-modal' import { AIAssistantActionBar } from './ai-assistant-action-bar' import { useLabels } from '@/context/LabelContext' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { NoteSize } from '@/lib/types' import { Badge } from '@/components/ui/badge' import { useLanguage } from '@/lib/i18n' interface NoteEditorProps { note: Note readOnly?: boolean onClose: () => void } export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) { 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(note.checkItems || []) const [labels, setLabels] = useState(note.labels || []) const [images, setImages] = useState(note.images || []) const [links, setLinks] = useState(note.links || []) const [newLabel, setNewLabel] = useState('') const [color, setColor] = useState(note.color) const [size, setSize] = useState(note.size || 'small') const [isSaving, setIsSaving] = useState(false) const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false) const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false) const fileInputRef = useRef(null) // Update context notebookId when note changes useEffect(() => { setContextNotebookId(note.notebookId || null) }, [note.notebookId, setContextNotebookId]) // Auto-tagging hook - use note.content from props instead of local state // This ensures triggering when notebookId changes (e.g., after moving note to notebook) const { suggestions, isAnalyzing } = useAutoTagging({ content: note.type === 'text' ? (note.content || '') : '', notebookId: note.notebookId, // Pass notebookId for contextual label suggestions (IA2) enabled: note.type === 'text' // Auto-tagging only for text notes }) // Reminder state const [showReminderDialog, setShowReminderDialog] = useState(false) const [currentReminder, setCurrentReminder] = useState(note.reminder) // Link state const [showLinkDialog, setShowLinkDialog] = useState(false) const [linkUrl, setLinkUrl] = useState('') // Title suggestions state const [titleSuggestions, setTitleSuggestions] = useState([]) 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) // Memory Echo Connections state const [comparisonNotes, setComparisonNotes] = useState>>([]) const [fusionNotes, setFusionNotes] = useState>>([]) // Tags rejetés par l'utilisateur pour cette session const [dismissedTags, setDismissedTags] = useState([]) const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default const handleSelectGhostTag = async (tag: string) => { // Vérification insensible à la casse const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase()) if (!tagExists) { setLabels(prev => [...prev, tag]) // Créer le label globalement s'il n'existe pas const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase()) if (!globalExists) { try { await addLabel(tag) } catch (err) { console.error('Erreur création label auto:', err) } } toast.success(t('ai.tagAdded', { tag })) } } const handleDismissGhostTag = (tag: string) => { setDismissedTags(prev => [...prev, tag]) } // Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur // (On garde celles déjà ajoutées pour les afficher en mode "validé") const filteredSuggestions = suggestions.filter(s => { if (!s || !s.tag) return false return !dismissedTags.includes(s.tag) }) const handleImageUpload = async (e: React.ChangeEvent) => { 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) => { setImages(images.filter((_, i) => i !== index)) } 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('Erreur génération titres:', 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('Erreur reformulation:', 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 || 'Failed to clarify') setContent(data.reformulatedText || data.text) toast.success(t('ai.reformulationApplied')) } catch (error) { console.error('Clarify error:', error) toast.error(t('ai.reformulationFailed')) } 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 || 'Failed to shorten') setContent(data.reformulatedText || data.text) toast.success(t('ai.reformulationApplied')) } catch (error) { console.error('Shorten error:', error) toast.error(t('ai.reformulationFailed')) } 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 || 'Failed to improve') setContent(data.reformulatedText || data.text) toast.success(t('ai.reformulationApplied')) } catch (error) { console.error('Improve error:', error) toast.error(t('ai.reformulationFailed')) } 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 || 'Failed to transform') // 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 = (date: Date) => { if (date < new Date()) { toast.error(t('notes.reminderPastError')) return } setCurrentReminder(date) toast.success(t('notes.reminderSet', { date: date.toLocaleString() })) } const handleRemoveReminder = () => { setCurrentReminder(null) toast.success(t('notes.reminderRemoved')) } 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, }) // Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans) await refreshLabels() // Rafraîchir la liste des notes 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')) onClose() // Force refresh to show the new note window.location.reload() } catch (error) { console.error('Failed to copy note:', error) toast.error(t('notes.copyFailed')) } } return ( {t('notes.edit')}

{readOnly ? t('notes.view') : t('notes.edit')}

{readOnly && ( {t('notes.readOnly')} )}
{/* Title */}
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" )} />
{/* Title Suggestions */} {!readOnly && titleSuggestions.length > 0 && ( setTitleSuggestions([])} /> )} {/* Images */} {/* Link Previews */} {links.length > 0 && (
{links.map((link, idx) => (
{link.imageUrl && (
)}

{link.title || link.url}

{link.description &&

{link.description}

} {new URL(link.url).hostname}
))}
)} {/* Content or Checklist */} {note.type === 'text' ? (
{/* Markdown controls */}
{isMarkdown && ( )}
{showMarkdownPreview && isMarkdown ? ( ) : (