Files
Momento/memento-note/components/note-input.tsx
Antigravity 91b1201112 refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:31:08 +00:00

1141 lines
44 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import {
CheckSquare,
X,
Bell,
Image,
UserPlus,
Palette,
Archive,
MoreVertical,
Undo2,
Redo2,
Eye,
Link as LinkIcon
} from 'lucide-react'
import { createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note, NoteType } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn, extractImagesFromHTML } from '@/lib/utils'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { GhostTags } from './ghost-tags'
import { TitleSuggestions } from './title-suggestions'
import { CollaboratorDialog } from './collaborator-dialog'
import { AIAssistantActionBar } from './ai-assistant-action-bar'
import { useSession } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
/** Convert basic markdown to HTML for richtext injection from AI chat */
function markdownToBasicHtml(md: string): string {
return md
.replace(/^#{6}\s(.+)$/gm, '<h6>$1</h6>')
.replace(/^#{5}\s(.+)$/gm, '<h5>$1</h5>')
.replace(/^#{4}\s(.+)$/gm, '<h4>$1</h4>')
.replace(/^#{3}\s(.+)$/gm, '<h3>$1</h3>')
.replace(/^#{2}\s(.+)$/gm, '<h2>$1</h2>')
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/^[-*]\s(.+)$/gm, '<li>$1</li>')
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/^(?!<[hH1-6]|<blockquote|<li|<pre|<p)(.+)$/gm, '<p>$1</p>')
.replace(/<p><\/p>/g, '')
}
interface HistoryState {
title: string
content: string
}
interface NoteState {
title: string
content: string
checkItems: CheckItem[]
images: string[]
}
interface NoteInputProps {
onNoteCreated?: (note: Note) => void
defaultExpanded?: boolean
forceExpanded?: boolean
/** Tab mode: takes full width of main content (no narrow centered card) */
fullWidth?: boolean
}
export function NoteInput({
onNoteCreated,
defaultExpanded = false,
forceExpanded = false,
fullWidth = false,
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useNotebooks()
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
const userId = session.user.id
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
getAISettings(userId).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
})
}
}, [session?.user?.id])
const { t, language: uiLanguage } = useLanguage()
const searchParams = useSearchParams()
const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL
const [isExpanded, setIsExpanded] = useState(defaultExpanded || forceExpanded)
// Sync with forceExpanded if it changes
useEffect(() => {
if (forceExpanded) setIsExpanded(true)
}, [forceExpanded])
const [type, setType] = useState<NoteType>('richtext')
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [isExpandedFull, setIsExpandedFull] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const { notebooks } = useNotebooks()
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [collaborators, setCollaborators] = useState<string[]>([])
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Simple state without complex undo/redo
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const isMarkdown = type === 'markdown'
const isRichText = type === 'richtext'
// Auto-resize textarea based on content
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}, [content, isExpandedFull, aiOpen])
// Combine text content and link metadata for AI analysis
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
].join(' ').trim();
const allImages = useMemo(() => {
const extracted = type === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, type]);
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type !== 'checklist' ? fullContentForAI : '',
enabled: type !== 'checklist' && isExpanded && autoLabelingEnabled,
notebookId: currentNotebookId
})
// Title suggestions
const titleSuggestionsEnabled = (type === 'text' || type === 'markdown' || type === 'richtext') && isExpanded && !title
const titleSuggestionsContent = type !== 'checklist' ? fullContentForAI : ''
// Title suggestions hook
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: titleSuggestionsContent,
enabled: titleSuggestionsEnabled
})
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const handleSelectGhostTag = async (tag: string) => {
// Case-insensitive check
const tagExists = selectedLabels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setSelectedLabels(prev => [...prev, tag])
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Error creating auto-label:', err)
}
}
toast.success(t('labels.tagAdded', { tag }))
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
}
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
// Undo/Redo history (title and content only)
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
const [historyIndex, setHistoryIndex] = useState(0)
const isUndoingRef = useRef(false)
// Reminder dialog
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
// Save to history after 1 second of inactivity
useEffect(() => {
if (isUndoingRef.current) {
isUndoingRef.current = false
return
}
const timer = setTimeout(() => {
const currentState = { title, content }
const lastState = history[historyIndex]
if (lastState.title !== title || lastState.content !== content) {
const newHistory = history.slice(0, historyIndex + 1)
newHistory.push(currentState)
if (newHistory.length > 50) {
newHistory.shift()
} else {
setHistoryIndex(historyIndex + 1)
}
setHistory(newHistory)
}
}, 1000)
return () => clearTimeout(timer)
}, [title, content, history, historyIndex])
// Undo/Redo functions
const handleUndo = () => {
if (historyIndex > 0) {
isUndoingRef.current = true
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setTitle(history[newIndex].title)
setContent(history[newIndex].content)
}
}
const handleRedo = () => {
if (historyIndex < history.length - 1) {
isUndoingRef.current = true
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setTitle(history[newIndex].title)
setContent(history[newIndex].content)
}
}
// AI Assistant state and handlers
const [isProcessingAI, setIsProcessingAI] = useState(false)
const handleClarify = 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', format: type === 'richtext' ? 'html' : 'markdown' })
})
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 handleShorten = 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', format: type === 'richtext' ? 'html' : 'markdown' })
})
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 handleImprove = 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', format: type === 'richtext' ? 'html' : 'markdown' })
})
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')
setContent(data.transformedText)
setType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
} catch (error) {
console.error('Transform to markdown error:', error)
toast.error(t('ai.transformError'))
} finally {
setIsProcessingAI(false)
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isExpanded) return
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
handleUndo()
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault()
handleRedo()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isExpanded, historyIndex, history])
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
// Validate file types
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const maxSize = 5 * 1024 * 1024 // 5MB
for (const file of Array.from(files)) {
// Validation
if (!validTypes.includes(file.type)) {
toast.error(t('notes.invalidFileType', { fileName: file.name }))
continue
}
if (file.size > maxSize) {
toast.error(t('notes.fileTooLarge', { fileName: file.name, maxSize: '5MB' }))
continue
}
// Upload to server
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 }))
}
}
// Reset input
e.target.value = ''
}
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
// Paste handler: upload clipboard images
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (type === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
}
}
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t])
// AI title from images
const [isGeneratingImageTitle, setIsGeneratingImageTitle] = useState(false)
const [imageTitleSuggestions, setImageTitleSuggestions] = useState<any[]>([])
const handleImageTitleSuggestion = async (imageUrls?: string[]) => {
const urls = imageUrls || images
if (urls.length === 0) return
setIsGeneratingImageTitle(true)
setImageTitleSuggestions([])
try {
const res = await fetch('/api/ai/describe-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrls: urls, mode: 'title', language: uiLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'AI error')
if (data.suggestions?.length > 0) {
setImageTitleSuggestions(data.suggestions)
}
} catch (e: any) {
toast.error(e.message || t('ai.genericError'))
} finally {
setIsGeneratingImageTitle(false)
}
}
const handleAddLink = async () => {
if (!linkUrl) return
// Optimistic add (or loading state)
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'))
// Fallback: just add the url as title
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 handleReminderOpen = () => {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
setShowReminderDialog(true)
}
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
toast.warning(t('notes.reminderDateTimeRequired'))
return
}
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
toast.error(t('notes.invalidDateTime'))
return
}
if (date < new Date()) {
toast.error(t('notes.reminderMustBeFuture'))
return
}
setCurrentReminder(date)
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
setShowReminderDialog(false)
setReminderDate('')
setReminderTime('')
}
const handleSubmit = async () => {
// Validation: Allow submit if content OR images OR links exist
const hasContent = type === 'checklist' ? false : content.trim().length > 0
const hasCheckItems = type === 'checklist' ? checkItems.some(item => item.text.trim()) : false
const hasMedia = (images && images.length > 0) || (links && links.length > 0)
if (type !== 'checklist' && !hasContent && !hasMedia) {
toast.warning(t('notes.contentOrMediaRequired'))
return
}
if (type === 'checklist' && !hasCheckItems && !hasMedia) {
toast.warning(t('notes.itemOrMediaRequired'))
return
}
setIsSubmitting(true)
try {
const createdNote = await createNote({
title: title.trim() || undefined,
content: type === 'checklist' ? '' : content,
type,
checkItems: type === 'checklist' ? checkItems : undefined,
color,
isArchived,
images: images.length > 0 ? images : undefined,
links: links.length > 0 ? links : undefined,
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
notebookId: currentNotebookId, // Assign note to current notebook if in one
})
// Notify parent component about the created note (for notebook suggestion)
if (createdNote && onNoteCreated) {
onNoteCreated(createdNote)
}
// Reset form
setTitle('')
setContent('')
setCheckItems([])
setImages([])
setLinks([])
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setIsExpanded(false)
setType('richtext')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
setImageTitleSuggestions([])
toast.success(t('notes.noteCreated'))
} catch (error) {
console.error('Failed to create note:', error)
toast.error(t('notes.noteCreateFailed'))
} finally {
setIsSubmitting(false)
}
}
const handleAddCheckItem = () => {
setCheckItems([
...checkItems,
{ id: Date.now().toString(), text: '', checked: false },
])
}
const handleUpdateCheckItem = (id: string, text: string) => {
setCheckItems(
checkItems.map(item => (item.id === id ? { ...item, text } : item))
)
}
const handleRemoveCheckItem = (id: string) => {
setCheckItems(checkItems.filter(item => item.id !== id))
}
const handleClose = () => {
setIsExpanded(false)
setTitle('')
setContent('')
setCheckItems([])
setImages([])
setLinks([])
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
setImageTitleSuggestions([])
}
const collapsedWidthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
if (!isExpanded) {
return (
<div
className={cn(
'mb-8 overflow-hidden rounded-lg border border-border bg-card shadow-[0_4px_20px_rgba(15,23,42,0.05)] transition-all duration-300 hover:shadow-[0_10px_30px_rgba(15,23,42,0.08)] cursor-text',
collapsedWidthClass
)}
onClick={() => setIsExpanded(true)}
>
<div className="flex items-center gap-2 px-4 py-2">
<Input dir="auto"
placeholder={t('notes.placeholder') || "Créer une note..."}
readOnly
value=""
className="border-0 bg-transparent focus-visible:ring-0 cursor-text h-10 text-base shadow-none font-medium text-foreground placeholder:text-muted-foreground/70"
/>
<div className="flex shrink-0 items-center gap-1 text-muted-foreground">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setType('checklist')
setIsExpanded(true)
}}
title={t('notes.newChecklist')}
className="h-8 w-8 rounded hover:bg-muted"
>
<CheckSquare className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
const widthClass = (aiOpen || isExpandedFull)
? 'w-full max-w-6xl mx-auto'
: fullWidth
? 'w-full mx-0'
: 'max-w-2xl mx-auto'
return (
<div
id="memento-note-composer"
className={cn(
'mb-8 flex flex-row items-stretch transition-all duration-300',
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
widthClass
)}>
{/* ── Note Card ── */}
<div className={cn(
'flex-1 flex flex-col overflow-hidden border border-border bg-card transition-all duration-200 relative min-w-[260px]',
aiOpen ? 'rounded-l-xl rounded-r-none border-r-0' : 'rounded-xl',
'shadow-sm focus-within:shadow-md focus-within:border-primary/40',
colorClasses.card
)}>
{/* Expand / shrink button — fixed top-right, hidden when AI panel is open */}
{!aiOpen && (
<button
type="button"
onClick={() => setIsExpandedFull(!isExpandedFull)}
className="absolute top-3 right-3 z-10 rounded-md p-1.5 text-muted-foreground/40 hover:bg-muted hover:text-foreground transition-colors"
title={isExpandedFull ? 'Réduire' : 'Agrandir'}
>
{isExpandedFull ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
)}
{/* Title row */}
<div className="px-5 pt-5 pb-2 pr-10">
<input
dir="auto"
className="w-full bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => { setTitle(e.target.value); setImageTitleSuggestions([]) }}
/>
</div>
{/* Title suggestions */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="px-5">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(s) => setTitle(s)}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Image title suggestions */}
{!title && imageTitleSuggestions.length > 0 && (
<div className="px-5">
<TitleSuggestions
suggestions={imageTitleSuggestions}
onSelect={(s) => { setTitle(s); setImageTitleSuggestions([]) }}
onDismiss={() => setImageTitleSuggestions([])}
/>
</div>
)}
{/* Content area — scrolls internally when constrained by max-h */}
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
{type === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[120px]"
onImageUpload={uploadImageFile}
/>
) : type === 'text' || type === 'markdown' ? (
<>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*Aucun contenu*'}
className="min-h-[120px] py-2 text-sm"
/>
) : (
<textarea
ref={textareaRef}
dir="auto"
className={cn(
'w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40 overflow-hidden',
isExpandedFull ? 'min-h-[400px]' : aiOpen ? 'min-h-[200px]' : 'min-h-[120px]'
)}
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
value={content}
onChange={(e) => setContent(e.target.value)}
autoFocus
/>
)}
{/* Images — rendered between content and tag suggestions */}
{images.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{images.map((img, idx) => (
<div key={idx} className="relative group inline-block w-fit">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
{!title && !content.trim() && aiAssistantEnabled && (
<button
type="button"
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
onClick={() => handleImageTitleSuggestion()}
disabled={isGeneratingImageTitle}
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
{isGeneratingImageTitle
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
</button>
)}
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
</>
) : (
<div className="space-y-1.5 py-2">
{/* Images — rendered before checklist items */}
{images.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{images.map((img, idx) => (
<div key={idx} className="relative group inline-block w-fit">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
{!title && !content.trim() && aiAssistantEnabled && (
<button
type="button"
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
onClick={() => handleImageTitleSuggestion()}
disabled={isGeneratingImageTitle}
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
{isGeneratingImageTitle
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
</button>
)}
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{checkItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 group">
<Checkbox className="shrink-0" />
<input
dir="auto"
value={item.text}
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
autoFocus={checkItems[checkItems.length - 1].id === item.id}
/>
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity rounded p-0.5 hover:bg-muted"
onClick={() => handleRemoveCheckItem(item.id)}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
))}
<button
type="button"
onClick={handleAddCheckItem}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
>
<X className="h-3.5 w-3.5 rotate-45" />
{t('notes.addListItem')}
</button>
</div>
)}
</div>
{/* Link previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">
{links.map((link, idx) => (
<div key={idx} className="relative group flex overflow-hidden rounded-lg border border-border/60 bg-muted/20">
{link.imageUrl && (
<div className="w-20 h-20 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex flex-col justify-center gap-0.5 p-3 min-w-0">
<p className="text-sm font-medium truncate">{link.title || link.url}</p>
{link.description && <p className="text-xs text-muted-foreground line-clamp-1">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline truncate">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute top-2 right-2 rounded-full bg-background/80 p-1 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* Selected labels */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-5 pb-3">
{selectedLabels.map(label => (
<LabelBadge key={label} label={label} onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))} />
))}
</div>
)}
{/* ── Toolbar ── */}
<div className="flex items-center justify-between border-t border-border/30 px-3 py-2 gap-2 flex-wrap">
<TooltipProvider>
<div className="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')} onClick={handleReminderOpen}>
<Bell className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.remindMe')}</TooltipContent></Tooltip>
<NoteTypeSelector value={type} onChange={(newType) => { setType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} compact />
{type === 'markdown' && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{showMarkdownPreview ? t('general.edit') : t('general.preview')}</TooltipContent></Tooltip>
)}
{aiAssistantEnabled && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon"
className={cn('h-8 w-8 transition-colors shrink-0', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>Ouvrir IA Note</TooltipContent></Tooltip>
)}
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => fileInputRef.current?.click()}>
<Image className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addImage')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowCollaboratorDialog(true)}>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addCollaborators')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowLinkDialog(true)}>
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addLink')}</TooltipContent></Tooltip>
<LabelSelector selectedLabels={selectedLabels} onLabelsChange={setSelectedLabels} variant="compact" align="start" />
<DropdownMenu>
<Tooltip><TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger><TooltipContent>{t('notes.backgroundOptions')}</TooltipContent></Tooltip>
<DropdownMenuContent align="start" className="w-40">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, colorClass]) => (
<button key={colorName} onClick={() => setColor(colorName as NoteColor)}
className={cn('w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform', colorClass.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-transparent')}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isArchived && 'text-amber-500')} onClick={() => setIsArchived(!isArchived)}>
<Archive className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{isArchived ? t('notes.unarchive') : t('notes.archive')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleUndo} disabled={historyIndex === 0}>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.undoShortcut')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleRedo} disabled={historyIndex >= history.length - 1}>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.redoShortcut')}</TooltipContent></Tooltip>
</div>
</TooltipProvider>
<div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm" onClick={handleClose} className="text-muted-foreground hover:text-foreground px-3 whitespace-nowrap">
{t('general.close')}
</Button>
<Button size="sm" onClick={handleSubmit} disabled={isSubmitting} className="px-5 font-medium whitespace-nowrap">
{isSubmitting ? t('notes.adding') : t('notes.add')}
</Button>
</div>
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
</div>
{/* ── AI Panel — direct child of flex-row so self-stretch works ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={allImages}
onApplyToNote={(newContent) => {
if (type === 'richtext') {
// If content looks like markdown, convert to HTML before injecting into richtext
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
} else {
setContent(newContent)
}
}}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={type === 'richtext' ? 'html' : 'markdown'}
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
/>
)}
{/* Dialogs */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogHeader><DialogTitle>{t('notes.setReminder')}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">{t('notes.date')}</label>
<input id="reminder-date" type="date" value={reminderDate} onChange={(e) => setReminderDate(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">{t('notes.time')}</label>
<input id="reminder-time" type="time" value={reminderTime} onChange={(e) => setReminderTime(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>{t('general.cancel')}</Button>
<Button onClick={handleReminderSave}>{t('notes.setReminderButton')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogHeader><DialogTitle>{t('notes.addLink')}</DialogTitle></DialogHeader>
<div className="py-4">
<input type="url" placeholder="https://example.com" value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddLink() } }}
autoFocus
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary" />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>{t('general.cancel')}</Button>
<Button onClick={handleAddLink}>{t('general.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId=""
noteOwnerId={session?.user?.id || ""}
currentUserId={session?.user?.id || ""}
onCollaboratorsChange={setCollaborators}
initialCollaborators={collaborators}
/>
</div>
)
}