All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
Chat (AIChat floating widget): conversationId was never captured from the API response, so every message created a new conversation with no context. Now creates the conversation upfront before streaming (same pattern as ChatContainer) so the ID persists across messages. Note history: was stored globally in UserAISettings, so enabling history on one note enabled it for ALL notes. Now each Note has its own historyEnabled boolean field. The "Enable history" action only affects the specific note. A migration adds the column with default false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
887 lines
36 KiB
TypeScript
887 lines
36 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,
|
|
toggleArchive,
|
|
deleteNote,
|
|
createNote,
|
|
commitNoteHistory,
|
|
} 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,
|
|
History,
|
|
GitCommitHorizontal,
|
|
} 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
|
|
onOpenHistory?: (note: Note) => void
|
|
onEnableHistory?: (noteId: string) => Promise<void>
|
|
noteHistoryMode?: 'manual' | 'auto'
|
|
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,
|
|
onOpenHistory,
|
|
onEnableHistory,
|
|
noteHistoryMode = 'manual',
|
|
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 toggleArchive(note.id, !note.isArchived)
|
|
triggerRefresh()
|
|
} 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={() => {
|
|
const nextIsMarkdown = !isMarkdown
|
|
setIsMarkdown(nextIsMarkdown)
|
|
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
|
|
if (!nextIsMarkdown) 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">
|
|
{note.historyEnabled && noteHistoryMode === 'manual' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
|
|
title={t('notes.commitVersion')}
|
|
onClick={() => {
|
|
startTransition(async () => {
|
|
try {
|
|
await commitNoteHistory(note.id)
|
|
toast.success(t('notes.versionSaved'))
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}}
|
|
>
|
|
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<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>
|
|
{onOpenHistory && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
if (note.historyEnabled) {
|
|
onOpenHistory(note)
|
|
} else if (onEnableHistory) {
|
|
onEnableHistory(note.id).then(() => onOpenHistory(note))
|
|
}
|
|
}}
|
|
>
|
|
<History className="h-4 w-4 mr-2" />
|
|
{note.historyEnabled
|
|
? (t('notes.history') || 'Historique')
|
|
: (t('notes.enableHistory') || "Activer l'historique")}
|
|
</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 suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
|
<span>·</span>
|
|
<span suppressHydrationWarning>{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>
|
|
)
|
|
}
|