'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 { useLanguage } from '@/lib/i18n' import { cn } from '@/lib/utils' import { updateNote, togglePin, toggleArchive, updateColor, deleteNote, } 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, } 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 { 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) => 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 } = useLabels() const [, startTransition] = useTransition() // ── Local edit state ────────────────────────────────────────────────────── const [title, setTitle] = useState(note.title || '') const [content, setContent] = useState(note.content || '') const [checkItems, setCheckItems] = useState(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([]) 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 }) } // 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(null) // Translate sub-panel const [showTranslate, setShowTranslate] = useState(false) const fileInputRef = useRef(null) const saveTimerRef = useRef | 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) } catch {} } toast.success(t('ai.tagAdded', { tag })) } } // ── 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 = () => { if (!confirm(t('notes.confirmDelete'))) return startTransition(async () => { await deleteNote(note.id) onDelete?.(note.id) }) } // ── Image upload ────────────────────────────────────────────────────────── 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 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 updateNote(note.id, { images: newImages }) } // ── 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 (
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
{/* Image upload */} {/* Link */} {/* Markdown toggle */} {isMarkdown && ( )} {/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */} {note.type === 'text' && ( { setAiOpen(o); if (!o) setShowTranslate(false) }}> {!showTranslate ? (
) : (
{[ { 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 }) => ( ))}
)} )} {/* ── Undo AI button ─────────────────────────────────────────────── */} {previousContent !== null && ( )}
{/* Save status indicator */} {isSaving ? ( <> Sauvegarde… ) : isDirty ? ( <> Modifié ) : ( <> Sauvegardé )} {/* Pin */} {/* Color picker */}
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
{/* More actions */} {note.isArchived ? <>{t('notes.unarchive')} : <>{t('notes.archive')}} {t('notes.delete')}
{/* ── Link input bar (inline) ───────────────────────────────────────── */} {showLinkInput && (
setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }} autoFocus />
)} {/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */} {((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
{/* Existing labels */} {(note.labels ?? []).map((label) => ( ))} {/* AI-suggested tags inline with labels */} setDismissedTags((p) => [...p, tag])} />
)} {/* ── Scrollable editing area (takes all remaining height) ─────────── */}
{/* Title row with optional AI suggest button */}
{ changeTitle(e.target.value); scheduleSave() }} /> {/* AI title suggestion — show when title is empty and there's content */} {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && ( )}
{/* Title Suggestions Dropdown / Inline list */} {!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
{ changeTitle(selectedTitle); scheduleSave() }} onDismiss={() => setDismissedTitleSuggestions(true)} />
)} {/* Images */} {Array.isArray(note.images) && note.images.length > 0 && (
)} {/* Link previews */} {Array.isArray(note.links) && note.links.length > 0 && (
{note.links.map((link, idx) => (
{link.imageUrl && (
)}

{link.title || link.url}

{link.description &&

{link.description}

} {(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
))}
)} {/* ── Text / Checklist content ───────────────────────────────────── */}
{note.type === 'text' ? (
{showMarkdownPreview && isMarkdown ? (
) : (