'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 { 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 } export function NoteInput({ onNoteCreated }: NoteInputProps) { const { labels: globalLabels, addLabel } = useLabels() const { data: session } = useSession() const { t } = useLanguage() const searchParams = useSearchParams() const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL const [isExpanded, setIsExpanded] = useState(false) const [type, setType] = useState<'text' | 'checklist'>('text') const [isSubmitting, setIsSubmitting] = useState(false) const [color, setColor] = useState('default') const [isArchived, setIsArchived] = useState(false) const [selectedLabels, setSelectedLabels] = useState([]) const [collaborators, setCollaborators] = useState([]) const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false) const fileInputRef = useRef(null) // Simple state without complex undo/redo - like Google Keep const [title, setTitle] = useState('') const [content, setContent] = useState('') const [checkItems, setCheckItems] = useState([]) const [images, setImages] = useState([]) const [links, setLinks] = useState([]) const [isMarkdown, setIsMarkdown] = useState(false) const [showMarkdownPreview, setShowMarkdownPreview] = useState(false) // 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 }) // 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([]) const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) const handleSelectGhostTag = async (tag: string) => { // Vérification insensible à la casse 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('Erreur création label auto:', 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([{ 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(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) => { 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, sharedWith: collaborators.length > 0 ? collaborators : 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) } if (!isExpanded) { return (
setIsExpanded(true)} readOnly value="" className="border-0 focus-visible:ring-0 cursor-text" />
) } const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default return ( <>
setTitle(e.target.value)} className="border-0 focus-visible:ring-0 text-base font-semibold" /> {/* Title Suggestions */} {!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && ( setTitle(selectedTitle)} onDismiss={() => setDismissedTitleSuggestions(true)} /> )} {/* Image Preview */} {images.length > 0 && (
{images.map((img, idx) => (
{`Upload
))}
)} {/* Link Previews */} {links.length > 0 && (
{links.map((link, idx) => (
{link.imageUrl && (
)}

{link.title || link.url}

{link.description &&

{link.description}

} {new URL(link.url).hostname}
))}
)} {/* Selected Labels Display */} {selectedLabels.length > 0 && (
{selectedLabels.map(label => ( setSelectedLabels(prev => prev.filter(l => l !== label))} /> ))}
)} {type === 'text' ? (
{/* Markdown toggle button */} {isMarkdown && (
)} {showMarkdownPreview && isMarkdown ? ( ) : (