Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
'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'
|
||
|
||
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-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([])
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
} |