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>
1122 lines
41 KiB
TypeScript
1122 lines
41 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } from '@/lib/types'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuSubContent,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
|
|
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
import { MarkdownContent } from './markdown-content'
|
|
import { LabelManager } from './label-manager'
|
|
import { LabelBadge } from './label-badge'
|
|
import { ReminderDialog } from './reminder-dialog'
|
|
import { EditorImages } from './editor-images'
|
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
|
import { GhostTags } from './ghost-tags'
|
|
import { TitleSuggestions } from './title-suggestions'
|
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
|
import { EditorConnectionsSection } from './editor-connections-section'
|
|
import { ComparisonModal } from './comparison-modal'
|
|
import { FusionModal } from './fusion-modal'
|
|
|
|
import { ContextualAIChat } from './contextual-ai-chat'
|
|
import { useLabels } from '@/context/LabelContext'
|
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { NoteSize } from '@/lib/types'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useSession } from 'next-auth/react'
|
|
import { getAISettings } from '@/app/actions/ai-settings'
|
|
|
|
interface NoteEditorProps {
|
|
note: Note
|
|
readOnly?: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
|
|
const { data: session } = useSession()
|
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
|
|
|
useEffect(() => {
|
|
if (session?.user?.id) {
|
|
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 { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId } = useLabels()
|
|
const { triggerRefresh } = useNoteRefresh()
|
|
const { t } = useLanguage()
|
|
const [title, setTitle] = useState(note.title || '')
|
|
const [content, setContent] = useState(note.content)
|
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
|
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
|
const [images, setImages] = useState<string[]>(note.images || [])
|
|
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
|
|
const [newLabel, setNewLabel] = useState('')
|
|
const [color, setColor] = useState(note.color)
|
|
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
|
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Update context notebookId when note changes
|
|
useEffect(() => {
|
|
setContextNotebookId(note.notebookId || null)
|
|
}, [note.notebookId, setContextNotebookId])
|
|
|
|
// Auto-tagging hook - use local state for live suggestions as user types
|
|
const { suggestions, isAnalyzing } = useAutoTagging({
|
|
content: note.type === 'text' ? content : '',
|
|
notebookId: note.notebookId,
|
|
enabled: note.type === 'text' && autoLabelingEnabled
|
|
})
|
|
|
|
// Reminder state
|
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
|
note.reminder ? new Date(note.reminder as unknown as string) : null
|
|
)
|
|
|
|
// Link state
|
|
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
|
const [linkUrl, setLinkUrl] = useState('')
|
|
|
|
// Title suggestions state
|
|
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
|
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
|
|
|
// Reformulation state
|
|
const [isReformulating, setIsReformulating] = useState(false)
|
|
const [reformulationModal, setReformulationModal] = useState<{
|
|
originalText: string
|
|
reformulatedText: string
|
|
option: string
|
|
} | null>(null)
|
|
|
|
// AI processing state for ActionBar
|
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
|
const [aiOpen, setAiOpen] = useState(false)
|
|
// Track previous content for copilot action undo
|
|
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
|
|
|
// Notebooks (for copilot chat scope)
|
|
const { notebooks } = useNotebooks()
|
|
|
|
// Memory Echo Connections state
|
|
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
|
|
|
// Tags dismissed by the user for this session
|
|
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
|
|
|
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
|
|
|
const handleSelectGhostTag = async (tag: string) => {
|
|
// Case-insensitive check
|
|
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
|
|
|
|
if (!tagExists) {
|
|
setLabels(prev => [...prev, tag])
|
|
|
|
// Create the label globally if it doesn't exist
|
|
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('ai.tagAdded', { tag }))
|
|
}
|
|
}
|
|
|
|
const handleDismissGhostTag = (tag: string) => {
|
|
setDismissedTags(prev => [...prev, tag])
|
|
}
|
|
|
|
// Filter suggestions to exclude dismissed ones
|
|
// and those already present on the note
|
|
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
|
const filteredSuggestions = suggestions.filter(s => {
|
|
if (!s || !s.tag) return false
|
|
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
|
})
|
|
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files) return
|
|
|
|
for (const file of Array.from(files)) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
try {
|
|
const 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 }))
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleRemoveImage = (index: number) => {
|
|
const removedUrl = images[index]
|
|
setImages(images.filter((_, i) => i !== index))
|
|
// Track removed images for cleanup on save
|
|
if (removedUrl) {
|
|
setRemovedImageUrls(prev => [...prev, removedUrl])
|
|
}
|
|
}
|
|
|
|
const handleAddLink = async () => {
|
|
if (!linkUrl) return
|
|
|
|
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'))
|
|
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 handleGenerateTitles = async () => {
|
|
// Combine content and link metadata for AI
|
|
const fullContent = [
|
|
content,
|
|
...links.map(l => `${l.title || ''} ${l.description || ''}`)
|
|
].join(' ').trim()
|
|
|
|
const wordCount = fullContent.split(/\s+/).filter(word => word.length > 0).length
|
|
|
|
if (wordCount < 10) {
|
|
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
|
return
|
|
}
|
|
|
|
setIsGeneratingTitles(true)
|
|
try {
|
|
const response = await fetch('/api/ai/title-suggestions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: fullContent }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
|
}
|
|
|
|
const data = await response.json()
|
|
setTitleSuggestions(data.suggestions || [])
|
|
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
|
|
} catch (error: any) {
|
|
console.error('Error generating titles:', error)
|
|
toast.error(error.message || t('ai.titleGenerationFailed'))
|
|
} finally {
|
|
setIsGeneratingTitles(false)
|
|
}
|
|
}
|
|
|
|
const handleSelectTitle = (title: string) => {
|
|
setTitle(title)
|
|
setTitleSuggestions([])
|
|
toast.success(t('ai.titleApplied'))
|
|
}
|
|
|
|
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
|
|
// Get selected text or full content
|
|
const selectedText = window.getSelection()?.toString()
|
|
|
|
if (!selectedText && (!content || content.trim().length === 0)) {
|
|
toast.error(t('ai.reformulationNoText'))
|
|
return
|
|
}
|
|
|
|
// If selection is too short, use full content instead
|
|
let textToReformulate: string
|
|
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
|
|
textToReformulate = selectedText
|
|
} else {
|
|
textToReformulate = content
|
|
if (selectedText) {
|
|
toast.info(t('ai.reformulationSelectionTooShort'))
|
|
}
|
|
}
|
|
|
|
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
|
|
|
|
if (wordCount < 10) {
|
|
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
|
return
|
|
}
|
|
|
|
if (wordCount > 500) {
|
|
toast.error(t('ai.reformulationMaxWords'))
|
|
return
|
|
}
|
|
|
|
setIsReformulating(true)
|
|
try {
|
|
const response = await fetch('/api/ai/reformulate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: textToReformulate,
|
|
option: option
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new Error(errorData.error || t('ai.reformulationError'))
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
// Show reformulation modal
|
|
setReformulationModal({
|
|
originalText: data.originalText,
|
|
reformulatedText: data.reformulatedText,
|
|
option: data.option
|
|
})
|
|
} catch (error: any) {
|
|
console.error('Error reformulating:', error)
|
|
toast.error(error.message || t('ai.reformulationFailed'))
|
|
} finally {
|
|
setIsReformulating(false)
|
|
}
|
|
}
|
|
|
|
// Simplified AI handlers for ActionBar (direct content update)
|
|
const handleClarifyDirect = 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 || t('notes.clarifyFailed'))
|
|
setContent(data.reformulatedText || data.text)
|
|
toast.success(t('ai.reformulationApplied'))
|
|
} catch (error) {
|
|
console.error('Clarify error:', error)
|
|
toast.error(t('notes.clarifyFailed'))
|
|
} finally {
|
|
setIsProcessingAI(false)
|
|
}
|
|
}
|
|
|
|
const handleShortenDirect = 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 || t('notes.shortenFailed'))
|
|
setContent(data.reformulatedText || data.text)
|
|
toast.success(t('ai.reformulationApplied'))
|
|
} catch (error) {
|
|
console.error('Shorten error:', error)
|
|
toast.error(t('notes.shortenFailed'))
|
|
} finally {
|
|
setIsProcessingAI(false)
|
|
}
|
|
}
|
|
|
|
const handleImproveDirect = 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 || t('notes.improveFailed'))
|
|
setContent(data.reformulatedText || data.text)
|
|
toast.success(t('ai.reformulationApplied'))
|
|
} catch (error) {
|
|
console.error('Improve error:', error)
|
|
toast.error(t('notes.improveFailed'))
|
|
} 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 || t('notes.transformFailed'))
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
const handleApplyRefactor = () => {
|
|
if (!reformulationModal) return
|
|
|
|
// If selected text exists, replace it
|
|
const selectedText = window.getSelection()?.toString()
|
|
if (selectedText) {
|
|
// For now, replace full content (TODO: improve to replace selection only)
|
|
setContent(reformulationModal.reformulatedText)
|
|
} else {
|
|
setContent(reformulationModal.reformulatedText)
|
|
}
|
|
|
|
setReformulationModal(null)
|
|
toast.success(t('ai.reformulationApplied'))
|
|
}
|
|
|
|
const handleReminderSave = async (date: Date) => {
|
|
if (date < new Date()) {
|
|
toast.error(t('notes.reminderPastError'))
|
|
return
|
|
}
|
|
setCurrentReminder(date)
|
|
try {
|
|
await updateNote(note.id, { reminder: date })
|
|
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
|
} catch {
|
|
toast.error(t('notebook.savingReminder'))
|
|
}
|
|
}
|
|
|
|
const handleRemoveReminder = async () => {
|
|
setCurrentReminder(null)
|
|
try {
|
|
await updateNote(note.id, { reminder: null })
|
|
toast.success(t('notes.reminderRemoved'))
|
|
} catch {
|
|
toast.error(t('notebook.removingReminder'))
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true)
|
|
try {
|
|
await updateNote(note.id, {
|
|
title: title.trim() || null,
|
|
content: note.type === 'text' ? content : '',
|
|
checkItems: note.type === 'checklist' ? checkItems : null,
|
|
labels,
|
|
images,
|
|
links,
|
|
color,
|
|
reminder: currentReminder,
|
|
isMarkdown,
|
|
size,
|
|
})
|
|
|
|
// Clean up removed image files from disk (best-effort, don't block save)
|
|
if (removedImageUrls.length > 0) {
|
|
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
|
}
|
|
|
|
// Refresh global labels to reflect any deletions (orphans)
|
|
await refreshLabels()
|
|
|
|
// Refresh the notes list
|
|
triggerRefresh()
|
|
|
|
onClose()
|
|
} catch (error) {
|
|
console.error('Failed to save note:', error)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCheckItem = (id: string) => {
|
|
setCheckItems(items =>
|
|
items.map(item =>
|
|
item.id === id ? { ...item, checked: !item.checked } : item
|
|
)
|
|
)
|
|
}
|
|
|
|
const handleUpdateCheckItem = (id: string, text: string) => {
|
|
setCheckItems(items =>
|
|
items.map(item => (item.id === id ? { ...item, text } : item))
|
|
)
|
|
}
|
|
|
|
const handleAddCheckItem = () => {
|
|
setCheckItems([
|
|
...checkItems,
|
|
{ id: Date.now().toString(), text: '', checked: false },
|
|
])
|
|
}
|
|
|
|
const handleRemoveCheckItem = (id: string) => {
|
|
setCheckItems(items => items.filter(item => item.id !== id))
|
|
}
|
|
|
|
const handleRemoveLabel = (label: string) => {
|
|
setLabels(labels.filter(l => l !== label))
|
|
}
|
|
|
|
const handleMakeCopy = async () => {
|
|
try {
|
|
const newNote = await createNote({
|
|
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
|
content: content,
|
|
color: color,
|
|
type: note.type,
|
|
checkItems: checkItems,
|
|
labels: labels,
|
|
images: images,
|
|
links: links,
|
|
isMarkdown: isMarkdown,
|
|
size: size,
|
|
})
|
|
toast.success(t('notes.copySuccess'))
|
|
triggerRefresh()
|
|
onClose()
|
|
} catch (error) {
|
|
console.error('Failed to copy note:', error)
|
|
toast.error(t('notes.copyFailed'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={true} onOpenChange={onClose}>
|
|
<DialogContent
|
|
className={cn(
|
|
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch',
|
|
colorClasses.bg
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
|
{/* AI Copilot Toggle Button next to title */}
|
|
{note.type === 'text' && !readOnly && aiAssistantEnabled && (
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
className={cn(
|
|
'h-8 gap-1.5 px-2 text-xs transition-colors ml-2',
|
|
aiOpen && 'bg-primary/10 text-primary'
|
|
)}
|
|
onClick={() => setAiOpen(!aiOpen)}
|
|
title="Toggle AI Copilot"
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">Assistant IA</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{readOnly && (
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
|
{t('notes.readOnly')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Title */}
|
|
<div className="relative">
|
|
<Input
|
|
placeholder={t('notes.titlePlaceholder')}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
disabled={readOnly}
|
|
className={cn(
|
|
"text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
|
|
readOnly && "cursor-default"
|
|
)}
|
|
/>
|
|
<button
|
|
onClick={handleGenerateTitles}
|
|
disabled={isGeneratingTitles || readOnly}
|
|
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
|
|
title={isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
|
|
>
|
|
{isGeneratingTitles ? (
|
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Title Suggestions */}
|
|
{!readOnly && titleSuggestions.length > 0 && (
|
|
<TitleSuggestions
|
|
suggestions={titleSuggestions}
|
|
onSelect={handleSelectTitle}
|
|
onDismiss={() => setTitleSuggestions([])}
|
|
/>
|
|
)}
|
|
|
|
{/* Images */}
|
|
<EditorImages images={images} onRemove={handleRemoveImage} />
|
|
|
|
{/* Link Previews */}
|
|
{links.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
{links.map((link, idx) => (
|
|
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
|
|
{link.imageUrl && (
|
|
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
)}
|
|
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
|
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
|
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
|
|
{new URL(link.url).hostname}
|
|
</a>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
|
|
onClick={() => handleRemoveLink(idx)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content or Checklist */}
|
|
{note.type === 'text' ? (
|
|
<div className="space-y-2">
|
|
{showMarkdownPreview && isMarkdown ? (
|
|
<MarkdownContent
|
|
content={content || t('notes.noContent')}
|
|
className="min-h-[200px] p-3 rounded-md border border-border/40 bg-muted/20"
|
|
/>
|
|
) : (
|
|
<Textarea
|
|
placeholder={isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
disabled={readOnly}
|
|
className={cn(
|
|
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
|
|
readOnly && "cursor-default"
|
|
)}
|
|
/>
|
|
)}
|
|
<GhostTags
|
|
suggestions={filteredSuggestions}
|
|
addedTags={labels}
|
|
isAnalyzing={isAnalyzing}
|
|
onSelectTag={handleSelectGhostTag}
|
|
onDismissTag={handleDismissGhostTag}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{checkItems.map((item) => (
|
|
<div key={item.id} className="flex items-start gap-2 group">
|
|
<Checkbox
|
|
checked={item.checked}
|
|
onCheckedChange={() => handleCheckItem(item.id)}
|
|
className="mt-2"
|
|
/>
|
|
<Input
|
|
value={item.text}
|
|
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
|
|
placeholder={t('notes.listItem')}
|
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
|
/>
|
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
|
onClick={() => handleRemoveCheckItem(item.id)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button variant="ghost" size="sm" onClick={handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t('notes.addItem')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Labels */}
|
|
{labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{labels.map((label) => (
|
|
<LabelBadge
|
|
key={label}
|
|
label={label}
|
|
onRemove={() => handleRemoveLabel(label)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Memory Echo Connections Section */}
|
|
{!readOnly && (
|
|
<EditorConnectionsSection
|
|
noteId={note.id}
|
|
onOpenNote={(noteId) => {
|
|
// Close current editor and reload page with the selected note
|
|
onClose()
|
|
window.location.href = `/?note=${noteId}`
|
|
}}
|
|
onCompareNotes={(noteIds) => {
|
|
// Note: noteIds already includes current note
|
|
// Fetch all notes for comparison
|
|
Promise.all(noteIds.map(async (id) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) {
|
|
console.error(`Failed to fetch note ${id}`)
|
|
return null
|
|
}
|
|
const data = await res.json()
|
|
if (data.success && data.data) {
|
|
return data.data
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error(`Error fetching note ${id}:`, error)
|
|
return null
|
|
}
|
|
}))
|
|
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
.then(fetchedNotes => {
|
|
setComparisonNotes(fetchedNotes)
|
|
})
|
|
}}
|
|
onMergeNotes={async (noteIds) => {
|
|
// Fetch notes for fusion (noteIds already includes current note)
|
|
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) {
|
|
console.error(`Failed to fetch note ${id}`)
|
|
return null
|
|
}
|
|
const data = await res.json()
|
|
if (data.success && data.data) {
|
|
return data.data
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error(`Error fetching note ${id}:`, error)
|
|
return null
|
|
}
|
|
}))
|
|
// Filter out nulls
|
|
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
|
<div className="flex items-center gap-0.5">
|
|
{!readOnly && (
|
|
<>
|
|
{/* Reminder */}
|
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')}
|
|
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
|
<Bell className="h-4 w-4" />
|
|
</Button>
|
|
{/* Add Image */}
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* Add Link */}
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* Markdown toggle */}
|
|
{note.type === 'text' && (
|
|
<Button variant="ghost" size="icon"
|
|
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
|
|
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}
|
|
title="Markdown">
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Markdown preview toggle */}
|
|
{isMarkdown && (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
|
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* AI Copilot */}
|
|
{note.type === 'text' && aiAssistantEnabled && (
|
|
<Button variant="ghost" size="sm"
|
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
|
|
onClick={() => setAiOpen(!aiOpen)} title="Assistant IA">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">Assistant IA</span>
|
|
</Button>
|
|
)}
|
|
|
|
{/* Size Selector */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeSize')}>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="flex flex-col gap-1 p-1">
|
|
{(['small', 'medium', 'large'] as const).map((s) => (
|
|
<Button key={s} variant="ghost" size="sm"
|
|
onClick={() => setSize(s)}
|
|
className={cn('justify-start capitalize', size === s && 'bg-accent')}>
|
|
{s}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Color Picker */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
|
|
<Palette className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="grid grid-cols-5 gap-2 p-2">
|
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
|
<button key={colorName}
|
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
|
|
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
|
onClick={() => setColor(colorName)} title={colorName} />
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Label Manager */}
|
|
<LabelManager existingLabels={labels} notebookId={note.notebookId} onUpdate={setLabels} />
|
|
</>
|
|
)}
|
|
{readOnly && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{readOnly ? (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
onClick={handleMakeCopy}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
{t('notes.makeCopy')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
|
onClick={async () => {
|
|
try {
|
|
await leaveSharedNote(note.id)
|
|
toast.success(t('notes.leftShare') || 'Share removed')
|
|
triggerRefresh()
|
|
onClose()
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{t('notes.leaveShare')}
|
|
</Button>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.close')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? t('notes.saving') : t('general.save')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── AI Copilot Side Panel ── */}
|
|
{aiOpen && (
|
|
<ContextualAIChat
|
|
onClose={() => setAiOpen(false)}
|
|
noteTitle={title}
|
|
noteContent={content}
|
|
onApplyToNote={(newContent) => {
|
|
setPreviousContentForCopilot(content)
|
|
setContent(newContent)
|
|
}}
|
|
onUndoLastAction={previousContentForCopilot !== null ? () => {
|
|
setContent(previousContentForCopilot)
|
|
setPreviousContentForCopilot(null)
|
|
} : undefined}
|
|
lastActionApplied={previousContentForCopilot !== null}
|
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<ReminderDialog
|
|
open={showReminderDialog}
|
|
onOpenChange={setShowReminderDialog}
|
|
currentReminder={currentReminder}
|
|
onSave={handleReminderSave}
|
|
onRemove={handleRemoveReminder}
|
|
/>
|
|
|
|
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('notes.addLink')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<Input
|
|
placeholder="https://example.com"
|
|
value={linkUrl}
|
|
onChange={(e) => setLinkUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleAddLink()
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={handleAddLink}>
|
|
{t('general.add')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Reformulation Modal */}
|
|
{reformulationModal && (
|
|
<Dialog open={!!reformulationModal} onOpenChange={() => setReformulationModal(null)}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4 py-4">
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
|
|
{reformulationModal.originalText}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
|
|
{t('ai.reformulated')} ({reformulationModal.option})
|
|
</h3>
|
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
|
|
{reformulationModal.reformulatedText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setReformulationModal(null)}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={handleApplyRefactor}>
|
|
{t('general.apply')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
{/* Comparison Modal */}
|
|
{comparisonNotes && comparisonNotes.length > 0 && (
|
|
<ComparisonModal
|
|
isOpen={!!comparisonNotes}
|
|
onClose={() => setComparisonNotes([])}
|
|
notes={comparisonNotes}
|
|
onOpenNote={(noteId) => {
|
|
// Close current editor and open the selected note
|
|
onClose()
|
|
// Trigger navigation to the note
|
|
window.location.href = `/?note=${noteId}`
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Fusion Modal */}
|
|
{fusionNotes && fusionNotes.length > 0 && (
|
|
<FusionModal
|
|
isOpen={!!fusionNotes}
|
|
onClose={() => setFusionNotes([])}
|
|
notes={fusionNotes}
|
|
onConfirmFusion={async ({ title, content }, options) => {
|
|
// Create the fused note
|
|
await createNote({
|
|
title,
|
|
content,
|
|
labels: options.keepAllTags
|
|
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
|
: fusionNotes[0].labels || [],
|
|
color: fusionNotes[0].color,
|
|
type: 'text',
|
|
isMarkdown: true, // AI generates markdown content
|
|
autoGenerated: true, // Mark as AI-generated fused note
|
|
aiProvider: 'fusion',
|
|
notebookId: fusionNotes[0].notebookId ?? undefined // Keep the notebook from the first note, convert null to undefined
|
|
})
|
|
|
|
// Archive original notes if option is selected
|
|
if (options.archiveOriginals) {
|
|
for (const note of fusionNotes) {
|
|
if (note.id) {
|
|
await updateNote(note.id, { isArchived: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
toast.success(t('toast.notesFusionSuccess'))
|
|
triggerRefresh()
|
|
onClose()
|
|
}}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
)
|
|
} |