Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
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>
988 lines
36 KiB
TypeScript
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>
|
|
)
|
|
}
|