Harmonise la liste des notes et l’éditeur (titres, dates, retour), rend la saisie rapide et les raccourcis de l’accueil utilisables sur téléphone, et conserve le panneau latéral repliable.
336 lines
14 KiB
TypeScript
336 lines
14 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 { NoteCoverHero } from './note-cover-hero'
|
||
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 { dateTextDirection, formatNoteCalendarDate } 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 { useEffect, useRef, 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 dateDir = dateTextDirection(language)
|
||
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 scrollRootRef = useRef<HTMLDivElement>(null)
|
||
|
||
// Always open a note at the top (title visible) — never inherit previous scroll
|
||
useEffect(() => {
|
||
const el = scrollRootRef.current
|
||
if (!el) return
|
||
el.scrollTop = 0
|
||
}, [note.id])
|
||
|
||
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
|
||
ref={scrollRootRef}
|
||
className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0 max-md:scroll-pb-24 max-md:pb-16"
|
||
>
|
||
|
||
{/* 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-4 sm:px-12 py-8 sm:py-16 space-y-8 sm:space-y-12 min-w-0">
|
||
|
||
{/* Breadcrumb + Title block */}
|
||
<div className="space-y-4">
|
||
{/* Breadcrumb: Notebook › Date */}
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] font-bold min-w-0">
|
||
{notebookName && (
|
||
<span
|
||
className="uppercase tracking-[.25em] min-w-0 break-words [overflow-wrap:anywhere]"
|
||
dir="auto"
|
||
style={{ color: 'var(--color-ink)' }}
|
||
>
|
||
{notebookName}
|
||
</span>
|
||
)}
|
||
{notebookName && <ChevronRight size={10} className="shrink-0 rtl:rotate-180" style={{ color: 'var(--color-concrete)' }} />}
|
||
<span
|
||
suppressHydrationWarning
|
||
className="font-medium tracking-normal normal-case"
|
||
dir={dateDir}
|
||
lang={language === 'fa' ? 'fa' : language === 'ar' ? 'ar' : undefined}
|
||
style={{ color: 'var(--color-concrete)' }}
|
||
>
|
||
{formatNoteCalendarDate(note.contentUpdatedAt, language)}
|
||
</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>
|
||
|
||
{/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
|
||
<NoteCoverHero
|
||
coverUrl={state.coverImage}
|
||
candidates={state.allImages}
|
||
readOnly={readOnly}
|
||
title={state.title}
|
||
onSetCover={actions.setCoverImage}
|
||
onClearCover={actions.clearCoverImage}
|
||
/>
|
||
|
||
{/* Content area — max-w-4xl for wider reading column */}
|
||
<div className="max-w-4xl mx-auto w-full space-y-8 pb-40 md: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([])
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
} |