## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1108 lines
36 KiB
TypeScript
1108 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 { 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<NoteColor>('default')
|
|
const [isArchived, setIsArchived] = useState(false)
|
|
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
|
|
const [collaborators, setCollaborators] = useState<string[]>([])
|
|
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Simple state without complex undo/redo - like Google Keep
|
|
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)
|
|
|
|
// 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<string[]>([])
|
|
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<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,
|
|
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 (
|
|
<Card className="p-4 max-w-2xl mx-auto mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center gap-4">
|
|
<Input
|
|
placeholder={t('notes.placeholder')}
|
|
onClick={() => setIsExpanded(true)}
|
|
readOnly
|
|
value=""
|
|
className="border-0 focus-visible:ring-0 cursor-text"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setType('checklist')
|
|
setIsExpanded(true)
|
|
}}
|
|
title={t('notes.newChecklist')}
|
|
>
|
|
<CheckSquare className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
|
|
|
return (
|
|
<>
|
|
<Card className={cn(
|
|
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
|
colorClasses.card
|
|
)}>
|
|
<div className="space-y-3">
|
|
<Input
|
|
placeholder={t('notes.titlePlaceholder')}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="border-0 focus-visible:ring-0 text-base font-semibold"
|
|
/>
|
|
|
|
{/* Title Suggestions */}
|
|
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
|
|
<TitleSuggestions
|
|
suggestions={titleSuggestions}
|
|
onSelect={(selectedTitle) => setTitle(selectedTitle)}
|
|
onDismiss={() => setDismissedTitleSuggestions(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* Image Preview */}
|
|
{images.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
{images.map((img, idx) => (
|
|
<div key={idx} className="relative group">
|
|
<img
|
|
src={img}
|
|
alt={`Upload ${idx + 1}`}
|
|
className="max-w-full h-auto max-h-96 object-contain rounded-lg"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={() => setImages(images.filter((_, i) => i !== idx))}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Link Previews */}
|
|
{links.length > 0 && (
|
|
<div className="flex flex-col gap-2 mt-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-blue-500 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>
|
|
)}
|
|
|
|
{/* Selected Labels Display */}
|
|
{selectedLabels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{selectedLabels.map(label => (
|
|
<LabelBadge
|
|
key={label}
|
|
label={label}
|
|
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{type === 'text' ? (
|
|
<div className="space-y-2">
|
|
{/* Markdown toggle button */}
|
|
{isMarkdown && (
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
|
className="h-7 text-xs"
|
|
>
|
|
{showMarkdownPreview ? (
|
|
<>
|
|
<FileText className="h-3 w-3 mr-1" />
|
|
{t('general.edit')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-3 w-3 mr-1" />
|
|
{t('general.preview')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{showMarkdownPreview && isMarkdown ? (
|
|
<MarkdownContent
|
|
content={content || '*No content*'}
|
|
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
|
/>
|
|
) : (
|
|
<Textarea
|
|
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
|
autoFocus
|
|
/>
|
|
)}
|
|
|
|
{/* AI Auto-tagging Suggestions */}
|
|
<GhostTags
|
|
suggestions={filteredSuggestions}
|
|
addedTags={selectedLabels}
|
|
isAnalyzing={isAnalyzing}
|
|
onSelectTag={handleSelectGhostTag}
|
|
onDismissTag={handleDismissGhostTag}
|
|
/>
|
|
|
|
{/* AI Assistant ActionBar */}
|
|
{type === 'text' && (
|
|
<AIAssistantActionBar
|
|
onClarify={handleClarify}
|
|
onShorten={handleShorten}
|
|
onImprove={handleImprove}
|
|
onTransformMarkdown={handleTransformMarkdown}
|
|
isMarkdownMode={isMarkdown}
|
|
disabled={isProcessingAI || !content}
|
|
className="mt-3"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{checkItems.map((item) => (
|
|
<div key={item.id} className="flex items-start gap-2 group">
|
|
<Checkbox 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"
|
|
autoFocus={checkItems[checkItems.length - 1].id === item.id}
|
|
/>
|
|
<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 w-full justify-start"
|
|
>
|
|
{t('notes.addListItem')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-2">
|
|
<TooltipProvider>
|
|
<div className="flex items-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"h-8 w-8",
|
|
currentReminder && "text-blue-600"
|
|
)}
|
|
title={t('notes.remindMe')}
|
|
onClick={handleReminderOpen}
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('notes.remindMe')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"h-8 w-8",
|
|
isMarkdown && "text-blue-600"
|
|
)}
|
|
onClick={() => {
|
|
setIsMarkdown(!isMarkdown)
|
|
if (isMarkdown) setShowMarkdownPreview(false)
|
|
}}
|
|
title={t('notes.markdown')}
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('notes.markdown')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
title={t('notes.addImage')}
|
|
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"
|
|
title={t('notes.addCollaborators')}
|
|
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)}
|
|
title={t('notes.addLink')}
|
|
>
|
|
<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" title={t('notes.backgroundOptions')}>
|
|
<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-yellow-600"
|
|
)}
|
|
onClick={() => setIsArchived(!isArchived)}
|
|
title={t('notes.archive')}
|
|
>
|
|
<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" title={t('notes.more')}>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('notes.more')}</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 gap-2">
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting}
|
|
size="sm"
|
|
>
|
|
{isSubmitting ? t('notes.adding') : t('notes.add')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleClose}
|
|
size="sm"
|
|
>
|
|
{t('general.close')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
/>
|
|
</Card>
|
|
|
|
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
|
<DialogContent
|
|
onInteractOutside={(event) => {
|
|
// Prevent dialog from closing when interacting with Sonner toasts
|
|
const target = event.target as HTMLElement;
|
|
|
|
const isSonnerElement =
|
|
target.closest('[data-sonner-toast]') ||
|
|
target.closest('[data-sonner-toaster]') ||
|
|
target.closest('[data-icon]') ||
|
|
target.closest('[data-content]') ||
|
|
target.closest('[data-description]') ||
|
|
target.closest('[data-title]') ||
|
|
target.closest('[data-button]');
|
|
|
|
if (isSonnerElement) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (target.getAttribute('data-sonner-toaster') !== null) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}}
|
|
>
|
|
<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"
|
|
/>
|
|
</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"
|
|
/>
|
|
</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
|
|
onInteractOutside={(event) => {
|
|
// Prevent dialog from closing when interacting with Sonner toasts
|
|
const target = event.target as HTMLElement;
|
|
|
|
const isSonnerElement =
|
|
target.closest('[data-sonner-toast]') ||
|
|
target.closest('[data-sonner-toaster]') ||
|
|
target.closest('[data-icon]') ||
|
|
target.closest('[data-content]') ||
|
|
target.closest('[data-description]') ||
|
|
target.closest('[data-title]') ||
|
|
target.closest('[data-button]');
|
|
|
|
if (isSonnerElement) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (target.getAttribute('data-sonner-toaster') !== null) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<CollaboratorDialog
|
|
open={showCollaboratorDialog}
|
|
onOpenChange={setShowCollaboratorDialog}
|
|
noteId=""
|
|
noteOwnerId={session?.user?.id || ""}
|
|
currentUserId={session?.user?.id || ""}
|
|
onCollaboratorsChange={setCollaborators}
|
|
initialCollaborators={collaborators}
|
|
/>
|
|
</>
|
|
)
|
|
} |