1093 lines
45 KiB
TypeScript
1093 lines
45 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 {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
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,
|
|
togglePin,
|
|
toggleArchive,
|
|
updateColor,
|
|
deleteNote,
|
|
removeImageFromNote,
|
|
leaveSharedNote,
|
|
createNote,
|
|
} 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,
|
|
Wand2,
|
|
AlignLeft,
|
|
Minimize2,
|
|
Lightbulb,
|
|
RotateCcw,
|
|
Languages,
|
|
ChevronRight,
|
|
Copy,
|
|
LogOut,
|
|
} 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 { formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale/fr'
|
|
import { enUS } from 'date-fns/locale/en-US'
|
|
|
|
interface NoteInlineEditorProps {
|
|
note: Note
|
|
onDelete?: (noteId: string) => void
|
|
onArchive?: (noteId: string) => void
|
|
onChange?: (noteId: string, fields: Partial<Note>) => void
|
|
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,
|
|
colorKey,
|
|
defaultPreviewMode = false,
|
|
}: NoteInlineEditorProps) {
|
|
const { t, language } = useLanguage()
|
|
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
|
const [, startTransition] = useTransition()
|
|
const { triggerRefresh } = useNoteRefresh()
|
|
|
|
const isSharedNote = !!(note as any)._isShared
|
|
|
|
// ── 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 }) }
|
|
|
|
// Textarea ref for formatting toolbar
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
|
const textarea = textAreaRef.current
|
|
if (!textarea) return
|
|
|
|
const start = textarea.selectionStart
|
|
const end = textarea.selectionEnd
|
|
const selected = content.substring(start, end)
|
|
const before = content.substring(0, start)
|
|
const after = content.substring(end)
|
|
|
|
const newContent = before + prefix + selected + suffix + after
|
|
changeContent(newContent)
|
|
scheduleSave()
|
|
|
|
// Restore cursor position after React re-renders
|
|
requestAnimationFrame(() => {
|
|
textarea.focus()
|
|
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
|
textarea.setSelectionRange(
|
|
selected ? start + prefix.length : start + prefix.length,
|
|
selected ? end + prefix.length : newCursorPos
|
|
)
|
|
})
|
|
}
|
|
|
|
// Link dialog
|
|
const [linkUrl, setLinkUrl] = useState('')
|
|
const [showLinkInput, setShowLinkInput] = useState(false)
|
|
const [isAddingLink, setIsAddingLink] = useState(false)
|
|
|
|
// AI popover
|
|
const [aiOpen, setAiOpen] = useState(false)
|
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
|
// Undo after AI: saves content before transformation
|
|
const [previousContent, setPreviousContent] = useState<string | null>(null)
|
|
// Translate sub-panel
|
|
const [showTranslate, setShowTranslate] = useState(false)
|
|
|
|
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',
|
|
})
|
|
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)
|
|
// Refresh labels to get the new color assignment
|
|
await refreshLabels()
|
|
} catch {}
|
|
}
|
|
toast.success(t('ai.tagAdded', { tag }))
|
|
}
|
|
}
|
|
|
|
const handleRemoveLabel = async (label: string) => {
|
|
const newLabels = (note.labels || []).filter((l) => l !== label)
|
|
// Optimistic UI
|
|
onChange?.(note.id, { labels: newLabels })
|
|
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
|
toast.success(t('labels.labelRemoved', { label }))
|
|
}
|
|
|
|
// ── Shared note actions ────────────────────────────────────────────────────
|
|
const handleMakeCopy = async () => {
|
|
try {
|
|
await createNote({
|
|
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
|
content,
|
|
color: note.color,
|
|
type: note.type,
|
|
checkItems: note.checkItems ?? undefined,
|
|
labels: note.labels ?? undefined,
|
|
images: note.images ?? undefined,
|
|
links: note.links ?? undefined,
|
|
isMarkdown,
|
|
})
|
|
toast.success(t('notes.copySuccess'))
|
|
triggerRefresh()
|
|
} catch (error) {
|
|
toast.error(t('notes.copyFailed'))
|
|
}
|
|
}
|
|
|
|
const handleLeaveShare = async () => {
|
|
try {
|
|
await leaveSharedNote(note.id)
|
|
toast.success(t('notes.leftShare') || 'Share removed')
|
|
triggerRefresh()
|
|
onDelete?.(note.id)
|
|
} catch (error) {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleMergeNotes = 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 }
|
|
}))
|
|
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
}
|
|
|
|
const handleCompareNotes = 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 }
|
|
}))
|
|
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
}
|
|
|
|
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,
|
|
notebookId: fusionNotes[0].notebookId ?? undefined
|
|
})
|
|
if (options.archiveOriginals) {
|
|
for (const n of fusionNotes) {
|
|
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
|
}
|
|
}
|
|
toast.success(t('toast.notesFusionSuccess'))
|
|
setFusionNotes([])
|
|
triggerRefresh()
|
|
}
|
|
|
|
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
|
const handleTogglePin = () => {
|
|
startTransition(async () => {
|
|
// Optimitistic update
|
|
onChange?.(note.id, { isPinned: !note.isPinned })
|
|
// Call with skipRevalidation to avoid server layout refresh interfering with optimistic state
|
|
await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true })
|
|
toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
|
|
})
|
|
}
|
|
|
|
const handleToggleArchive = () => {
|
|
startTransition(async () => {
|
|
onArchive?.(note.id)
|
|
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
|
|
})
|
|
}
|
|
|
|
const handleColorChange = (color: string) => {
|
|
startTransition(async () => {
|
|
onChange?.(note.id, { color })
|
|
await updateNote(note.id, { color }, { skipRevalidation: true })
|
|
})
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
toast(t('notes.confirmDelete'), {
|
|
action: {
|
|
label: t('notes.delete'),
|
|
onClick: () => {
|
|
startTransition(async () => {
|
|
await deleteNote(note.id)
|
|
onDelete?.(note.id)
|
|
})
|
|
},
|
|
},
|
|
cancel: {
|
|
label: t('common.cancel'),
|
|
onClick: () => {},
|
|
},
|
|
duration: 5000,
|
|
})
|
|
}
|
|
|
|
// ── Image upload ──────────────────────────────────────────────────────────
|
|
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 res = await fetch('/api/upload', { method: 'POST', body: formData })
|
|
if (!res.ok) throw new Error('Upload failed')
|
|
const data = await res.json()
|
|
const newImages = [...(note.images || []), data.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 handleRemoveImage = async (index: number) => {
|
|
const newImages = (note.images || []).filter((_, i) => i !== index)
|
|
onChange?.(note.id, { images: newImages })
|
|
await removeImageFromNote(note.id, index)
|
|
}
|
|
|
|
// ── 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 })
|
|
}
|
|
|
|
// ── AI actions (called from Popover in toolbar) ───────────────────────────
|
|
const callAI = async (option: 'clarify' | 'shorten' | 'improve') => {
|
|
const wc = content.split(/\s+/).filter(Boolean).length
|
|
if (!content || wc < 10) {
|
|
toast.error(t('ai.reformulationMinWords', { count: wc }))
|
|
return
|
|
}
|
|
setAiOpen(false)
|
|
setShowTranslate(false)
|
|
setPreviousContent(content) // save for undo
|
|
setIsProcessingAI(true)
|
|
try {
|
|
const res = await fetch('/api/ai/reformulate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: content, option }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error || 'Failed to reformulate')
|
|
changeContent(data.reformulatedText || data.text)
|
|
scheduleSave()
|
|
toast.success(t('ai.reformulationApplied'))
|
|
} catch {
|
|
toast.error(t('ai.reformulationFailed'))
|
|
setPreviousContent(null)
|
|
} finally {
|
|
setIsProcessingAI(false)
|
|
}
|
|
}
|
|
|
|
const callTranslate = async (targetLanguage: string) => {
|
|
const wc = content.split(/\s+/).filter(Boolean).length
|
|
if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
|
|
setAiOpen(false)
|
|
setShowTranslate(false)
|
|
setPreviousContent(content)
|
|
setIsProcessingAI(true)
|
|
try {
|
|
const res = await fetch('/api/ai/translate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: content, targetLanguage }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error || 'Translation failed')
|
|
changeContent(data.translatedText)
|
|
scheduleSave()
|
|
toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`)
|
|
} catch {
|
|
toast.error(t('ai.translationFailed') || 'Traduction échouée')
|
|
setPreviousContent(null)
|
|
} finally {
|
|
setIsProcessingAI(false)
|
|
}
|
|
}
|
|
|
|
const handleTransformMarkdown = async () => {
|
|
const wc = content.split(/\s+/).filter(Boolean).length
|
|
if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
|
|
setAiOpen(false)
|
|
setShowTranslate(false)
|
|
setPreviousContent(content)
|
|
setIsProcessingAI(true)
|
|
try {
|
|
const res = await fetch('/api/ai/transform-markdown', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: content }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error)
|
|
changeContent(data.transformedText)
|
|
setIsMarkdown(true)
|
|
scheduleSave()
|
|
toast.success(t('ai.transformSuccess'))
|
|
} catch {
|
|
toast.error(t('ai.transformError'))
|
|
setPreviousContent(null)
|
|
} finally {
|
|
setIsProcessingAI(false)
|
|
}
|
|
}
|
|
|
|
// ── 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 flex-col overflow-hidden">
|
|
|
|
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
|
{isSharedNote && (
|
|
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
|
<span className="text-xs font-medium text-primary">
|
|
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
{t('notes.makeCopy') || 'Copier'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
{t('notes.leaveShare') || 'Quitter'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
|
{!isSharedNote && (
|
|
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{/* Image upload */}
|
|
<Button
|
|
variant="ghost" size="sm" className="h-8 w-8 p-0"
|
|
title={t('notes.addImage') || 'Ajouter une image'}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
|
|
|
{/* Link */}
|
|
<Button
|
|
variant="ghost" size="sm" className="h-8 w-8 p-0"
|
|
title={t('notes.addLink') || 'Ajouter un lien'}
|
|
onClick={() => setShowLinkInput(!showLinkInput)}
|
|
>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* Markdown toggle */}
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
className={cn('h-8 gap-1 px-2 text-xs', isMarkdown && 'text-primary')}
|
|
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
|
|
title="Markdown"
|
|
>
|
|
<FileText className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">MD</span>
|
|
</Button>
|
|
|
|
{isMarkdown && (
|
|
<Button
|
|
variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs"
|
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{showMarkdownPreview ? t('notes.edit') || 'Éditer' : t('notes.preview') || 'Aperçu'}</span>
|
|
</Button>
|
|
)}
|
|
|
|
{/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */}
|
|
{note.type === 'text' && (
|
|
<Popover open={aiOpen} onOpenChange={(o) => { setAiOpen(o); if (!o) setShowTranslate(false) }}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
className={cn(
|
|
'h-8 gap-1.5 px-2 text-xs transition-colors',
|
|
isProcessingAI && 'text-primary',
|
|
aiOpen && 'bg-muted text-primary',
|
|
)}
|
|
disabled={isProcessingAI}
|
|
title="Assistant IA"
|
|
>
|
|
{isProcessingAI
|
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
: <Sparkles className="h-3.5 w-3.5" />
|
|
}
|
|
<span className="hidden sm:inline">IA</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-56 p-1">
|
|
{!showTranslate ? (
|
|
<div className="flex flex-col gap-0.5">
|
|
<button type="button"
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
|
|
onClick={() => callAI('clarify')}
|
|
>
|
|
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('ai.clarify') || 'Clarifier'}</p>
|
|
<p className="text-[11px] text-muted-foreground">{t('ai.clarifyDesc') || 'Rendre plus clair'}</p>
|
|
</div>
|
|
</button>
|
|
<button type="button"
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
|
|
onClick={() => callAI('shorten')}
|
|
>
|
|
<Minimize2 className="h-4 w-4 text-blue-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('ai.shorten') || 'Raccourcir'}</p>
|
|
<p className="text-[11px] text-muted-foreground">{t('ai.shortenDesc') || 'Version concise'}</p>
|
|
</div>
|
|
</button>
|
|
<button type="button"
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
|
|
onClick={() => callAI('improve')}
|
|
>
|
|
<AlignLeft className="h-4 w-4 text-emerald-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('ai.improve') || 'Améliorer'}</p>
|
|
<p className="text-[11px] text-muted-foreground">{t('ai.improveDesc') || 'Meilleure rédaction'}</p>
|
|
</div>
|
|
</button>
|
|
<button type="button"
|
|
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left w-full"
|
|
onClick={() => setShowTranslate(true)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Languages className="h-4 w-4 text-sky-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('ai.translate') || 'Traduire'}</p>
|
|
<p className="text-[11px] text-muted-foreground">{t('ai.translateDesc') || 'Changer la langue'}</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
</button>
|
|
<div className="my-0.5 border-t border-border/40" />
|
|
<button type="button"
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
|
|
onClick={handleTransformMarkdown}
|
|
>
|
|
<Wand2 className="h-4 w-4 text-violet-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('ai.toMarkdown') || 'En Markdown'}</p>
|
|
<p className="text-[11px] text-muted-foreground">{t('ai.toMarkdownDesc') || 'Formater en MD'}</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5">
|
|
<button type="button"
|
|
className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => setShowTranslate(false)}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
{t('ai.translateBack') || 'Retour'}
|
|
</button>
|
|
<div className="my-0.5 border-t border-border/40" />
|
|
{[
|
|
{ code: 'French', label: 'Français 🇫🇷' },
|
|
{ code: 'English', label: 'English 🇬🇧' },
|
|
{ code: 'Persian', label: 'فارسی 🇮🇷' },
|
|
{ code: 'Spanish', label: 'Español 🇪🇸' },
|
|
{ code: 'German', label: 'Deutsch 🇩🇪' },
|
|
{ code: 'Italian', label: 'Italiano 🇮🇹' },
|
|
{ code: 'Portuguese', label: 'Português 🇵🇹' },
|
|
{ code: 'Arabic', label: 'العربية 🇸🇦' },
|
|
{ code: 'Chinese', label: '中文 🇨🇳' },
|
|
{ code: 'Japanese', label: '日本語 🇯🇵' },
|
|
].map(({ code, label }) => (
|
|
<button key={code} type="button"
|
|
className="w-full rounded-md px-3 py-1.5 text-sm hover:bg-muted text-left"
|
|
onClick={() => callTranslate(code)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
{/* ── Undo AI button ─────────────────────────────────────────────── */}
|
|
{previousContent !== null && (
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
className="h-8 gap-1.5 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
|
title={t('ai.undoAI') || 'Annuler transformation IA'}
|
|
onClick={() => {
|
|
changeContent(previousContent)
|
|
setPreviousContent(null)
|
|
scheduleSave()
|
|
toast.info(t('ai.undoApplied') || 'Texte original restauré')
|
|
}}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{t('ai.undo') || 'Annuler IA'}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{/* Save status indicator */}
|
|
<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" /> Sauvegarde…</>
|
|
) : isDirty ? (
|
|
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> Modifié</>
|
|
) : (
|
|
<><Check className="h-3 w-3 text-emerald-500" /> Sauvegardé</>
|
|
)}
|
|
</span>
|
|
|
|
{/* Color picker */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" 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>
|
|
|
|
{/* More actions */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" 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>
|
|
<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" /> : 'Ajouter'}
|
|
</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}
|
|
onRemove={() => handleRemoveLabel(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 (takes all remaining height) ─────────── */}
|
|
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
|
|
{/* Title row with optional AI suggest button */}
|
|
<div className="group relative flex items-start gap-2 shrink-0">
|
|
<input
|
|
type="text"
|
|
dir="auto"
|
|
className="flex-1 bg-transparent text-2xl font-bold 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() }}
|
|
readOnly={isSharedNote}
|
|
/>
|
|
{/* AI title suggestion — show when title is empty and there's content */}
|
|
{!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 { /* silent */ } finally { setIsProcessingAI(false) }
|
|
}}
|
|
disabled={isProcessingAI}
|
|
className="mt-1.5 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="Suggestion de titre par IA"
|
|
>
|
|
{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
|
|
ref={textAreaRef}
|
|
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') || 'Écris en Markdown…'
|
|
: t('notes.takeNote') || 'Écris quelque chose…'
|
|
}
|
|
value={content}
|
|
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
|
readOnly={isSharedNote}
|
|
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') || 'Élément…'}
|
|
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') || 'Ajouter un élément'}
|
|
</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">
|
|
Complétés ({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>
|
|
|
|
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
|
{!isSharedNote && (
|
|
<EditorConnectionsSection
|
|
noteId={note.id}
|
|
onMergeNotes={handleMergeNotes}
|
|
onCompareNotes={handleCompareNotes}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 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>{t('notes.modified') || 'Modifiée'} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
|
<span>·</span>
|
|
<span>{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>
|
|
)
|
|
}
|