All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
- Add stop button to all chat interfaces (floating, contextual, full-page) - Add conversation sliding window (50 messages) to prevent context overflow - Add chat timeout warning (30s toast) - Force response language in chat system prompt (mandatory per-locale) - Add image paste from clipboard in all note editors (card, list, input) - Fix upload API to infer extension from MIME type for clipboard images - Add image description support in note AI chat (base64 vision) - Fix search regex crash on special characters (escape user input) - Fix search case-insensitivity on PostgreSQL (mode: 'insensitive') - Add try/catch around semantic search in chat route (prevent blocking) - Add new chat button to floating AI assistant - Fix empty thinking bubbles for reasoning models (filter non-text parts) - Remove duplicate AI assistant toggle from note editor header - Improve link metadata scraping (timeout, content-type check, relative URLs) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
834 lines
34 KiB
TypeScript
834 lines
34 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
|
|
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { LabelBadge } from '@/components/label-badge'
|
|
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
|
import { FusionModal } from '@/components/fusion-modal'
|
|
import { ComparisonModal } from '@/components/comparison-modal'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
updateNote,
|
|
deleteNote,
|
|
createNote,
|
|
} from '@/app/actions/notes'
|
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
|
import {
|
|
Pin,
|
|
Palette,
|
|
Archive,
|
|
ArchiveRestore,
|
|
Trash2,
|
|
ImageIcon,
|
|
Link as LinkIcon,
|
|
X,
|
|
Plus,
|
|
CheckSquare,
|
|
FileText,
|
|
Eye,
|
|
Sparkles,
|
|
Loader2,
|
|
Check,
|
|
RotateCcw,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { MarkdownContent } from '@/components/markdown-content'
|
|
import { EditorImages } from '@/components/editor-images'
|
|
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
|
import { GhostTags } from '@/components/ghost-tags'
|
|
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
|
import { useLabels } from '@/context/LabelContext'
|
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale/fr'
|
|
import { enUS } from 'date-fns/locale/en-US'
|
|
import { useSession } from 'next-auth/react'
|
|
import { getAISettings } from '@/app/actions/ai-settings'
|
|
|
|
interface NoteInlineEditorProps {
|
|
note: Note
|
|
onDelete?: (noteId: string) => void
|
|
onArchive?: (noteId: string) => void
|
|
onChange?: (noteId: string, fields: Partial<Note>) => void
|
|
colorKey: NoteColor
|
|
/** If true and the note is a Markdown note, open directly in preview mode */
|
|
defaultPreviewMode?: boolean
|
|
}
|
|
|
|
function getDateLocale(language: string) {
|
|
if (language === 'fr') return fr;
|
|
if (language === 'fa') return require('date-fns/locale').faIR;
|
|
return enUS;
|
|
}
|
|
|
|
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
|
|
async function saveInline(
|
|
id: string,
|
|
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
|
|
) {
|
|
await fetch(`/api/notes/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export function NoteInlineEditor({
|
|
note,
|
|
onDelete,
|
|
onArchive,
|
|
onChange,
|
|
colorKey,
|
|
defaultPreviewMode = false,
|
|
}: NoteInlineEditorProps) {
|
|
const { t, language } = useLanguage()
|
|
const { data: session } = useSession()
|
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
|
|
|
useEffect(() => {
|
|
if (session?.user?.id) {
|
|
const userId = session.user.id
|
|
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
|
|
getAISettings(userId).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 } = useLabels()
|
|
const [, startTransition] = useTransition()
|
|
const { triggerRefresh } = useNoteRefresh()
|
|
|
|
// ── Local edit state ──────────────────────────────────────────────────────
|
|
const [title, setTitle] = useState(note.title || '')
|
|
const [content, setContent] = useState(note.content || '')
|
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
|
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
|
|
defaultPreviewMode && (note.isMarkdown || false)
|
|
)
|
|
const [isDirty, setIsDirty] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
|
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
|
|
|
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
|
|
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
|
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
|
|
|
// Link dialog
|
|
const [linkUrl, setLinkUrl] = useState('')
|
|
const [showLinkInput, setShowLinkInput] = useState(false)
|
|
const [isAddingLink, setIsAddingLink] = useState(false)
|
|
|
|
// AI side panel
|
|
const [aiOpen, setAiOpen] = useState(false)
|
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
|
// Undo after AI copilot applies content
|
|
const [previousContent, setPreviousContent] = useState<string | null>(null)
|
|
|
|
// Notebooks list (for copilot chat scope)
|
|
const { notebooks } = useNotebooks()
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
|
|
const noteIdRef = useRef(note.id)
|
|
|
|
// Title suggestions
|
|
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
|
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
|
|
content: note.type === 'text' ? content : '',
|
|
enabled: note.type === 'text' && !title
|
|
})
|
|
|
|
// Keep pending ref in sync for unmount save
|
|
useEffect(() => {
|
|
pendingRef.current = { title, content, checkItems, isMarkdown }
|
|
}, [title, content, checkItems, isMarkdown])
|
|
|
|
// ── Sync when selected note switches ─────────────────────────────────────
|
|
useEffect(() => {
|
|
// Flush unsaved changes for the PREVIOUS note before switching
|
|
if (isDirty && noteIdRef.current !== note.id) {
|
|
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
|
saveInline(noteIdRef.current, {
|
|
title: t.trim() || null,
|
|
content: c,
|
|
checkItems: note.type === 'checklist' ? ci : undefined,
|
|
isMarkdown: im,
|
|
}).catch(() => {})
|
|
}
|
|
|
|
noteIdRef.current = note.id
|
|
setTitle(note.title || '')
|
|
setContent(note.content || '')
|
|
setCheckItems(note.checkItems || [])
|
|
setIsMarkdown(note.isMarkdown || false)
|
|
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
|
|
setIsDirty(false)
|
|
setDismissedTitleSuggestions(false)
|
|
clearTimeout(saveTimerRef.current)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [note.id])
|
|
|
|
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
|
const scheduleSave = useCallback(() => {
|
|
setIsDirty(true)
|
|
clearTimeout(saveTimerRef.current)
|
|
saveTimerRef.current = setTimeout(async () => {
|
|
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
|
setIsSaving(true)
|
|
try {
|
|
await saveInline(noteIdRef.current, {
|
|
title: t.trim() || null,
|
|
content: c,
|
|
checkItems: note.type === 'checklist' ? ci : undefined,
|
|
isMarkdown: im,
|
|
})
|
|
setIsDirty(false)
|
|
} catch {
|
|
// silent — retry on next keystroke
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}, 1500)
|
|
}, [note.type])
|
|
|
|
// Flush on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
clearTimeout(saveTimerRef.current)
|
|
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
|
|
saveInline(noteIdRef.current, {
|
|
title: t.trim() || null,
|
|
content: c,
|
|
checkItems: note.type === 'checklist' ? ci : undefined,
|
|
isMarkdown: im,
|
|
}).catch(() => {})
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// ── Auto-tagging ──────────────────────────────────────────────────────────
|
|
const { suggestions, isAnalyzing } = useAutoTagging({
|
|
content: note.type === 'text' ? content : '',
|
|
notebookId: note.notebookId,
|
|
enabled: note.type === 'text' && autoLabelingEnabled,
|
|
})
|
|
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
|
const filteredSuggestions = suggestions.filter(
|
|
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
|
)
|
|
const handleSelectGhostTag = async (tag: string) => {
|
|
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
|
|
if (!exists) {
|
|
const newLabels = [...(note.labels || []), tag]
|
|
// Optimistic UI — update sidebar immediately, no page refresh needed
|
|
onChange?.(note.id, { labels: newLabels })
|
|
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
|
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
|
if (!globalExists) {
|
|
try { await addLabel(tag) } catch {}
|
|
}
|
|
toast.success(t('ai.tagAdded', { tag }))
|
|
}
|
|
}
|
|
|
|
const fetchNotesByIds = async (noteIds: string[]) => {
|
|
const fetched = await Promise.all(noteIds.map(async (id) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) return null
|
|
const data = await res.json()
|
|
return data.success && data.data ? data.data : null
|
|
} catch { return null }
|
|
}))
|
|
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
|
|
}
|
|
|
|
const handleMergeNotes = async (noteIds: string[]) => {
|
|
setFusionNotes(await fetchNotesByIds(noteIds))
|
|
}
|
|
|
|
const handleCompareNotes = async (noteIds: string[]) => {
|
|
setComparisonNotes(await fetchNotesByIds(noteIds))
|
|
}
|
|
|
|
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
|
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,
|
|
autoGenerated: true,
|
|
aiProvider: 'fusion',
|
|
notebookId: fusionNotes[0].notebookId ?? undefined
|
|
})
|
|
if (options.archiveOriginals) {
|
|
for (const n of fusionNotes) {
|
|
if (n.id) await updateNote(n.id, { isArchived: true })
|
|
}
|
|
}
|
|
toast.success(t('toast.notesFusionSuccess'))
|
|
setFusionNotes([])
|
|
triggerRefresh()
|
|
}
|
|
|
|
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
|
const handleTogglePin = () => {
|
|
const prev = note.isPinned
|
|
startTransition(async () => {
|
|
onChange?.(note.id, { isPinned: !prev })
|
|
try {
|
|
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
|
|
toast.success(prev ? t('notes.unpinned') : t('notes.pinned') )
|
|
} catch {
|
|
onChange?.(note.id, { isPinned: prev })
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleToggleArchive = () => {
|
|
startTransition(async () => {
|
|
onArchive?.(note.id)
|
|
try {
|
|
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
|
|
} catch {
|
|
// Cannot easily revert since onArchive removes from list
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleColorChange = (color: string) => {
|
|
const prev = color
|
|
startTransition(async () => {
|
|
onChange?.(note.id, { color })
|
|
try {
|
|
await updateNote(note.id, { color }, { skipRevalidation: true })
|
|
} catch {
|
|
onChange?.(note.id, { color: prev })
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
if (!confirm(t('notes.confirmDelete'))) return
|
|
startTransition(async () => {
|
|
await deleteNote(note.id)
|
|
onDelete?.(note.id)
|
|
triggerRefresh()
|
|
})
|
|
}
|
|
|
|
// ── Image upload ──────────────────────────────────────────────────────────
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files) return
|
|
for (const file of Array.from(files)) {
|
|
try {
|
|
const url = await uploadImageFile(file)
|
|
const newImages = [...(note.images || []), url]
|
|
onChange?.(note.id, { images: newImages })
|
|
await updateNote(note.id, { images: newImages })
|
|
} catch {
|
|
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
|
}
|
|
}
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
}
|
|
|
|
const uploadImageFile = async (file: File) => {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
|
if (!res.ok) throw new Error('Upload failed')
|
|
const data = await res.json()
|
|
return data.url
|
|
}
|
|
|
|
// Paste handler: upload clipboard images
|
|
useEffect(() => {
|
|
const handlePaste = async (e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items
|
|
if (!items) return
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault()
|
|
const file = item.getAsFile()
|
|
if (!file) continue
|
|
try {
|
|
const url = await uploadImageFile(file)
|
|
const newImages = [...(note.images || []), url]
|
|
onChange?.(note.id, { images: newImages })
|
|
await updateNote(note.id, { images: newImages })
|
|
} catch {
|
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener('paste', handlePaste)
|
|
return () => document.removeEventListener('paste', handlePaste)
|
|
}, [note.id, note.images, onChange, t])
|
|
|
|
const handleRemoveImage = async (index: number) => {
|
|
const newImages = (note.images || []).filter((_, i) => i !== index)
|
|
onChange?.(note.id, { images: newImages })
|
|
await updateNote(note.id, { images: newImages })
|
|
}
|
|
|
|
// ── Link ──────────────────────────────────────────────────────────────────
|
|
const handleAddLink = async () => {
|
|
if (!linkUrl) return
|
|
setIsAddingLink(true)
|
|
try {
|
|
const metadata = await fetchLinkMetadata(linkUrl)
|
|
const newLink = metadata || { url: linkUrl, title: linkUrl }
|
|
const newLinks = [...(note.links || []), newLink]
|
|
onChange?.(note.id, { links: newLinks })
|
|
await updateNote(note.id, { links: newLinks })
|
|
toast.success(t('notes.linkAdded'))
|
|
} catch {
|
|
toast.error(t('notes.linkAddFailed'))
|
|
} finally {
|
|
setLinkUrl('')
|
|
setShowLinkInput(false)
|
|
setIsAddingLink(false)
|
|
}
|
|
}
|
|
|
|
const handleRemoveLink = async (index: number) => {
|
|
const newLinks = (note.links || []).filter((_, i) => i !== index)
|
|
onChange?.(note.id, { links: newLinks })
|
|
await updateNote(note.id, { links: newLinks })
|
|
}
|
|
|
|
// ── Checklist helpers ─────────────────────────────────────────────────────
|
|
const handleToggleCheckItem = (id: string) => {
|
|
const updated = checkItems.map((ci) =>
|
|
ci.id === id ? { ...ci, checked: !ci.checked } : ci
|
|
)
|
|
setCheckItems(updated)
|
|
scheduleSave()
|
|
}
|
|
|
|
const handleUpdateCheckText = (id: string, text: string) => {
|
|
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
|
|
setCheckItems(updated)
|
|
scheduleSave()
|
|
}
|
|
|
|
const handleAddCheckItem = () => {
|
|
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
|
|
setCheckItems(updated)
|
|
scheduleSave()
|
|
}
|
|
|
|
const handleRemoveCheckItem = (id: string) => {
|
|
const updated = checkItems.filter((ci) => ci.id !== id)
|
|
setCheckItems(updated)
|
|
scheduleSave()
|
|
}
|
|
|
|
const dateLocale = getDateLocale(language)
|
|
|
|
return (
|
|
<div className="flex h-full w-full overflow-hidden">
|
|
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
|
|
|
|
{/* ── Toolbar ───────────────────────────────────────────────── */}
|
|
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
|
|
|
|
{/* Left group: content tools */}
|
|
<div className="flex items-center gap-0.5">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
title={t('notes.addImage') }
|
|
onClick={() => fileInputRef.current?.click()}>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
title={t('notes.addLink') }
|
|
onClick={() => setShowLinkInput(!showLinkInput)}>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button variant="ghost" size="icon"
|
|
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
|
|
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
|
|
title="Markdown">
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{isMarkdown && (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
|
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{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={t('ai.aiCopilot')}>
|
|
{isProcessingAI
|
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
: <Sparkles className="h-3.5 w-3.5" />}
|
|
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
|
|
</Button>
|
|
)}
|
|
|
|
{previousContent !== null && (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
|
|
title={t('ai.undoAI') }
|
|
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right group: meta actions + save indicator */}
|
|
<div className="flex items-center gap-1">
|
|
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
|
|
{isSaving ? (
|
|
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
|
) : isDirty ? (
|
|
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
|
) : (
|
|
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
|
)}
|
|
</span>
|
|
|
|
|
|
|
|
<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 align="end">
|
|
<div className="grid grid-cols-5 gap-2 p-2">
|
|
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
|
|
<button type="button" key={name}
|
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
|
|
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
|
onClick={() => handleColorChange(name)} title={name} />
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
|
|
<span className="text-base leading-none text-muted-foreground">⋯</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={handleToggleArchive}>
|
|
{note.isArchived
|
|
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
|
|
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
|
|
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
|
{showLinkInput && (
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
|
|
<input
|
|
type="url"
|
|
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
|
placeholder="https://..."
|
|
value={linkUrl}
|
|
onChange={(e) => setLinkUrl(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
|
|
autoFocus
|
|
/>
|
|
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
|
|
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
|
|
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
|
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
|
{/* Existing labels */}
|
|
{(note.labels ?? []).map((label) => (
|
|
<LabelBadge key={label} label={label} />
|
|
))}
|
|
{/* AI-suggested tags inline with labels */}
|
|
<GhostTags
|
|
suggestions={filteredSuggestions}
|
|
addedTags={note.labels || []}
|
|
isAnalyzing={isAnalyzing}
|
|
onSelectTag={handleSelectGhostTag}
|
|
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Scrollable editing area ── */}
|
|
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
|
{/* Title */}
|
|
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
|
<input
|
|
type="text"
|
|
dir="auto"
|
|
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
|
|
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
|
value={title}
|
|
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
|
/>
|
|
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
|
<button type="button"
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
setIsProcessingAI(true)
|
|
try {
|
|
const res = await fetch('/api/ai/suggest-title', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content }),
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const suggested = data.title || data.suggestedTitle || ''
|
|
if (suggested) { changeTitle(suggested); scheduleSave() }
|
|
}
|
|
} catch { } finally { setIsProcessingAI(false) }
|
|
}}
|
|
disabled={isProcessingAI}
|
|
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
|
|
title={t('ai.suggestTitle')}
|
|
>
|
|
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Title Suggestions Dropdown / Inline list */}
|
|
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
|
|
<div className="mt-2 text-sm shrink-0">
|
|
<TitleSuggestions
|
|
suggestions={titleSuggestions}
|
|
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
|
|
onDismiss={() => setDismissedTitleSuggestions(true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Images */}
|
|
{Array.isArray(note.images) && note.images.length > 0 && (
|
|
<div className="mt-4">
|
|
<EditorImages images={note.images} onRemove={handleRemoveImage} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Link previews */}
|
|
{Array.isArray(note.links) && note.links.length > 0 && (
|
|
<div className="mt-4 flex flex-col gap-2">
|
|
{note.links.map((link, idx) => (
|
|
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
|
{link.imageUrl && (
|
|
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
)}
|
|
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
|
|
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
|
|
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
|
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
|
|
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
|
|
</a>
|
|
</div>
|
|
<button type="button"
|
|
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
|
|
onClick={() => handleRemoveLink(idx)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Text / Checklist content ───────────────────────────────────── */}
|
|
<div className="mt-4 flex flex-1 flex-col">
|
|
{note.type === 'text' ? (
|
|
<div className="flex flex-1 flex-col">
|
|
{showMarkdownPreview && isMarkdown ? (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
|
|
<MarkdownContent content={content || ''} />
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
dir="auto"
|
|
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
|
placeholder={isMarkdown
|
|
? t('notes.takeNoteMarkdown')
|
|
: t('notes.takeNote')
|
|
}
|
|
value={content}
|
|
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
|
style={{ minHeight: '200px' }}
|
|
/>
|
|
)}
|
|
|
|
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
|
</div>
|
|
) : (
|
|
/* Checklist */
|
|
<div className="space-y-1">
|
|
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
|
|
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
|
|
<button type="button"
|
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
|
|
onClick={() => handleToggleCheckItem(ci.id)}
|
|
/>
|
|
<input
|
|
dir="auto"
|
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
|
|
value={ci.text}
|
|
placeholder={t('notes.listItem') }
|
|
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
|
|
/>
|
|
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
|
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<button type="button"
|
|
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
|
|
onClick={handleAddCheckItem}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('notes.addItem') }
|
|
</button>
|
|
|
|
{checkItems.filter((ci) => ci.checked).length > 0 && (
|
|
<div className="mt-3">
|
|
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
|
|
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
|
|
</p>
|
|
{checkItems.filter((ci) => ci.checked).map((ci) => (
|
|
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
|
|
<button type="button"
|
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
|
|
onClick={() => handleToggleCheckItem(ci.id)}
|
|
>
|
|
<CheckSquare className="h-3 w-3 opacity-60" />
|
|
</button>
|
|
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
|
|
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Memory Echo Connections Section ── */}
|
|
<EditorConnectionsSection
|
|
noteId={note.id}
|
|
onOpenNote={(connNoteId) => {
|
|
window.open(`/?note=${connNoteId}`, '_blank')
|
|
}}
|
|
onCompareNotes={handleCompareNotes}
|
|
onMergeNotes={handleMergeNotes}
|
|
/>
|
|
|
|
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
|
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
|
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
|
|
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
|
<span>·</span>
|
|
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fusion Modal */}
|
|
{fusionNotes.length > 0 && (
|
|
<FusionModal
|
|
isOpen={fusionNotes.length > 0}
|
|
onClose={() => setFusionNotes([])}
|
|
notes={fusionNotes}
|
|
onConfirmFusion={handleConfirmFusion}
|
|
/>
|
|
)}
|
|
|
|
{/* Comparison Modal */}
|
|
{comparisonNotes.length > 0 && (
|
|
<ComparisonModal
|
|
isOpen={comparisonNotes.length > 0}
|
|
onClose={() => setComparisonNotes([])}
|
|
notes={comparisonNotes}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── AI Copilot Side Panel ── */}
|
|
{aiOpen && (
|
|
<ContextualAIChat
|
|
onClose={() => setAiOpen(false)}
|
|
noteTitle={title}
|
|
noteContent={content}
|
|
noteImages={note.images || undefined}
|
|
onApplyToNote={(newContent) => {
|
|
setPreviousContent(content)
|
|
changeContent(newContent)
|
|
scheduleSave()
|
|
}}
|
|
onUndoLastAction={previousContent !== null ? () => {
|
|
changeContent(previousContent)
|
|
setPreviousContent(null)
|
|
scheduleSave()
|
|
} : undefined}
|
|
lastActionApplied={previousContent !== null}
|
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|