Files
Keep/keep-notes/components/note-inline-editor.tsx

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>
)
}