Files
Momento/memento-note/components/note-input.tsx
sepehr 153c921960
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Replaced ~100+ hardcoded French and English text strings across 30+ components
with proper i18n t() calls. Added 57 new translation keys to all 15 locale files
(ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh).

Key changes:
- contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders)
- ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history)
- note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist)
- lab-skeleton.tsx: French loading text → t()
- admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed
- New AI chat component, agent cards, sidebar, settings panel i18n cleanup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:14:45 +02:00

988 lines
36 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } 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,
FileText,
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 } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles } 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 } from '@/lib/utils'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
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 { useLabels } from '@/context/LabelContext'
import { useSession } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
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 } = useLabels()
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
getAISettings(session.user.id).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 } = 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<'text' | 'checklist'>('text')
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 [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// 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();
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded && autoLabelingEnabled,
notebookId: currentNotebookId
})
// Title suggestions
const titleSuggestionsEnabled = type === 'text' && isExpanded && !title
const titleSuggestionsContent = type === 'text' ? 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' })
})
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' })
})
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' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to improve')
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
toast.error(t('ai.reformulationFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to transform')
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText)
setIsMarkdown(true)
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
} catch (error) {
console.error('Transform to markdown error:', error)
toast.error(t('ai.transformError'))
} finally {
setIsProcessingAI(false)
}
}
// 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 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 = content.trim().length > 0;
const hasMedia = images.length > 0 || links.length > 0;
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0);
if (type === 'text' && !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 === 'text' ? 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([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setIsExpanded(false)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
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([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
setDismissedTitleSuggestions(false)
}
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 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)}
/>
</div>
{/* Title suggestions */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="px-5">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(s) => setTitle(s)}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</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 === 'text' ? (
<>
{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
/>
)}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
</>
) : (
<div className="space-y-1.5 py-2">
{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>
{/* Images */}
{images.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain" />
<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>
)}
{/* 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">
<TooltipProvider>
<div className="flex items-center gap-0.5 flex-nowrap overflow-hidden 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>
{type === 'text' && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.markdown')}</TooltipContent></Tooltip>
)}
{type === 'text' && isMarkdown && (
<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>
)}
{type === 'text' && aiAssistantEnabled && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors shrink-0', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}>
<Sparkles className="h-3.5 w-3.5" />
<span>Assistant IA</span>
</Button>
</TooltipTrigger><TooltipContent>Ouvrir le copilote IA</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} triggerLabel="" 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}
onApplyToNote={(newContent) => setContent(newContent)}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
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>
)
}