Files
Momento/memento-note/components/note-editor/note-editor-full-page.tsx
Antigravity c271754cfa
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m21s
CI / Deploy production (on server) (push) Successful in 23s
feat: note editor plus large — max-w-5xl body + max-w-4xl content + dialog 1800px
Full page editor:
- Container: max-w-4xl → max-w-5xl (896px → 1024px)
- Content column: max-w-3xl → max-w-4xl (768px → 896px)
- Toolbar: max-w-5xl mx-auto (aligné avec le body)

Dialog editor:
- max-w 1600px → 1800px, 95vw → 97vw
- Hauteur 90vh → 92vh

Résultat: ~128px de largeur de lecture supplémentaire sur grand écran
2026-07-04 21:52:48 +00:00

314 lines
13 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 { InlinePaywall } from '@/components/settings/inline-paywall'
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'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
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="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
{/* BODY — max-w-5xl, responsive px, py-16 */}
<div className="max-w-5xl 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 || state.quotaExceededFeature === 'auto_tag') && (
<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 && (
state.quotaExceededFeature === 'auto_tag' ? (
<InlinePaywall
feature="auto_tag"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
) : (
<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-4xl for wider reading column */}
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">
{state.quotaExceededFeature === 'reformulate' && (
<InlinePaywall
feature="reformulate"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
<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, options?: { asRichText?: boolean }) => {
actions.setPreviousContentForCopilot(state.content)
if (options?.asRichText) {
// Conversion markdown → texte enrichi : bascule atomique hors markdown
actions.convertToRichText(nc)
} else {
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([])
}}
/>
)}
</>
)
}