Files
Momento/memento-note/components/note-editor/note-editor-full-page.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

298 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { EditorImages } from '@/components/editor-images'
import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { ChevronRight } from 'lucide-react'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
import { GhostTags } from '@/components/ghost-tags'
import { LabelBadge } from '@/components/label-badge'
import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section'
import { useRouter } from 'next/navigation'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const router = useRouter()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const dateLocale = language === 'fr' ? fr : enUS
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
const [attachmentsCount, setAttachmentsCount] = useState(0)
const [uploadTrigger, setUploadTrigger] = useState(0)
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
const fetchNotesByIds = async (noteIds: string[]) => {
const notes = 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 as Partial<Note> : null
} catch {
return null
}
}))
return notes.filter((n): n is Partial<Note> => n !== null)
}
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
const getLabelType = (name: string): 'ai' | 'user' => {
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
return (found as any)?.type === 'ai' ? 'ai' : 'user'
}
return (
<>
{/* ── outer container ── */}
<div className="h-screen flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
{/* BODY — max-w-4xl, responsive px, py-16 */}
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
{/* Breadcrumb + Title block */}
<div className="space-y-4">
{/* Breadcrumb: Notebook Date */}
<div className="flex items-center gap-3 text-[12px] uppercase tracking-[.25em] font-bold">
{notebookName && <span style={{ color: 'var(--color-ink)' }}>{notebookName}</span>}
{notebookName && <ChevronRight size={10} style={{ color: 'var(--color-concrete)' }} />}
<span suppressHydrationWarning style={{ color: 'var(--color-concrete)' }}>
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
</span>
</div>
{/* Title */}
<NoteTitleBlock />
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && (
<div className="flex flex-wrap gap-2 pt-2">
{state.labels.map((label) => (
<LabelBadge
key={label}
label={label}
type={getLabelType(label)}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
{!readOnly && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div>
)}
</div>
{/* Hero image — show first note image if present */}
{state.allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={state.allImages[0]}
alt={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
<NoteContentArea />
{!readOnly && (
<MemoryEchoSection
noteId={note.id}
onCompareNotes={async (noteIds, meta) => {
setComparisonSimilarity(meta?.similarity)
actions.setComparisonNotes(await fetchNotesByIds(noteIds))
}}
onMergeNotes={async (noteIds) => {
actions.setFusionNotes(await fetchNotesByIds(noteIds))
}}
/>
)}
<NoteAttachments
noteId={note.id}
onOpenDocQA={(att) => setDocQAAttachment(att)}
onCountChange={setAttachmentsCount}
triggerUpload={uploadTrigger}
/>
{/* WikiLinks backlinks */}
<WikilinksBacklinksPanel noteId={note.id} />
</div>
</div>
</div>
{/* ── Side panel: AI Chat ── */}
{state.aiOpen && (
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
<ContextualAIChat
onClose={() => actions.setAiOpen(false)}
noteTitle={state.title}
noteContent={state.content}
noteImages={state.allImages}
noteId={note.id}
onApplyToNote={(nc: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(nc)
if (state.isMarkdown) actions.setShowMarkdownPreview(true)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
notebookId={note.notebookId ?? undefined}
notebookName={notebookName ?? undefined}
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
onGenerateTitle={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success(t('ai.titleApplied'))
} else {
toast.error(t('ai.titleGenerationFailed'))
}
} else {
toast.error(t('ai.titleGenerationError'))
}
} catch { toast.error(t('ai.networkErrorShort')) } finally { actions.setIsProcessingAI(false) }
}}
/>
</div>
)}
{/* ── Side panel: Document Info ── */}
{state.infoOpen && (
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
<NoteDocumentInfoPanel
note={note}
content={state.content}
onClose={() => actions.setInfoOpen(false)}
onNoteRestored={(r: Note) => { actions.setContent(r.content || ''); actions.setTitle(r.title || ''); actions.setIsDirty(false) }}
/>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
{docQAAttachment && (
<DocumentQAOverlay
attachment={docQAAttachment}
noteId={note.id}
noteContent={state.content}
onClose={() => setDocQAAttachment(null)}
onApplyToNote={(content) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(state.content + '\n\n' + content)
}}
/>
)}
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}
currentReminder={state.currentReminder}
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
{state.comparisonNotes.length > 0 && (
<ComparisonModal
isOpen
onClose={() => {
setComparisonSimilarity(undefined)
actions.setComparisonNotes([])
}}
notes={state.comparisonNotes}
similarity={comparisonSimilarity}
onMergeNotes={async (noteIds) => {
actions.setFusionNotes(await fetchNotesByIds(noteIds))
}}
/>
)}
{state.fusionNotes.length > 0 && (
<FusionModal
isOpen
onClose={() => actions.setFusionNotes([])}
notes={state.fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await actions.handleSaveInPlace()
const { createNote, updateNote } = await import('@/app/actions/notes')
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
: state.fusionNotes[0].labels || [],
color: state.fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: state.fusionNotes[0].notebookId ?? undefined,
})
if (options.archiveOriginals) {
for (const fusionNote of state.fusionNotes) {
if (fusionNote.id) {
await updateNote(fusionNote.id, { isArchived: true })
}
}
}
toast.success(t('toast.notesFusionSuccess'))
actions.setFusionNotes([])
}}
/>
)}
</>
)
}