'use client' import { useState, useRef, useCallback, useEffect } from 'react' import { useNoteEditorContext } from './note-editor-context' import { LabelManager } from '@/components/label-manager' import { LabelBadge } from '@/components/label-badge' import { GhostTags } from '@/components/ghost-tags' import { EditorImages } from '@/components/editor-images' import { TitleSuggestions } from '@/components/title-suggestions' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History } from 'lucide-react' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { NoteShareDialog } from './note-share-dialog' import { deleteNote, leaveSharedNote } from '@/app/actions/notes' import { emitNoteChange } from '@/lib/note-change-sync' import { useLanguage } from '@/lib/i18n' import { NOTE_COLORS, NoteColor, Note } from '@/lib/types' import { cn } from '@/lib/utils' import { useVoiceTranscription } from '@/hooks/use-voice-transcription' import { toast } from 'sonner' import { format } from 'date-fns' import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export' import { getCalloutColors } from '@/lib/editor/callout-colors' import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard' import { useAiConsent } from '@/components/legal/ai-consent-provider' import { PUBLISH_TEMPLATES, type PublishTemplateId } from '@/lib/publish/types' interface NoteEditorToolbarProps { mode: 'fullPage' | 'dialog' onClose: () => void onToggleAttachments?: () => void attachmentsCount?: number } export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) { const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext() const { t, language } = useLanguage() const { requestAiConsent } = useAiConsent() const [isConverting, setIsConverting] = useState(false) const [shareOpen, setShareOpen] = useState(false) const [flashcardsOpen, setFlashcardsOpen] = useState(false) const [publishOpen, setPublishOpen] = useState(false) const [publishLoading, setPublishLoading] = useState(false) const [publishMeta, setPublishMeta] = useState({ isPublic: Boolean(note.isPublic), slug: note.publicSlug ?? null, template: (note.publishedTemplate as PublishTemplateId | null) ?? null, }) const [publishLinkCopied, setPublishLinkCopied] = useState(false) const [publishTemplate, setPublishTemplate] = useState('magazine') const [publishRewrite, setPublishRewrite] = useState(false) const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState(null) const [publishEnhanceLocked, setPublishEnhanceLocked] = useState(false) const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null useEffect(() => { setPublishMeta({ isPublic: Boolean(note.isPublic), slug: note.publicSlug ?? null, template: (note.publishedTemplate as PublishTemplateId | null) ?? null, }) }, [note.id, note.isPublic, note.publicSlug, note.publishedTemplate]) useEffect(() => { if (!publishOpen) return const loadPublishQuota = () => { void fetch('/api/usage/current') .then((r) => r.ok ? r.json() : null) .then((data) => { const q = data?.quotas?.publish_enhance if (q === undefined) { setPublishEnhanceLocked(false) setPublishEnhanceRemaining(null) return } if (q.limit === 0) { setPublishEnhanceLocked(true) setPublishEnhanceRemaining(0) return } setPublishEnhanceLocked(false) setPublishEnhanceRemaining(q.remaining ?? 0) }) .catch(() => { setPublishEnhanceLocked(false) setPublishEnhanceRemaining(null) }) } loadPublishQuota() window.addEventListener('ai-usage-changed', loadPublishQuota) return () => window.removeEventListener('ai-usage-changed', loadPublishQuota) }, [publishOpen]) const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null) // ── Voice transcription ────────────────────────────────────────────────── const handleTranscript = useCallback((text: string) => { const editor = richTextEditorRef?.current?.getEditor() if (editor) { editor.chain().focus().insertContent(' ' + text).run() } }, [richTextEditorRef]) const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({ onTranscript: handleTranscript, }) // ── Markdown export ─────────────────────────────────────────────────────── const handleExportMarkdown = () => { try { const editor = richTextEditorRef?.current?.getEditor() if (!editor) { toast.error(t('richTextEditor.markdownExportError')) return } const html = editor.getHTML() const title = state.title || note.title || 'note' const titleLine = title ? `# ${title}\n\n` : '' const markdown = titleLine + tiptapHTMLToMarkdown(html) const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) toast.success(t('richTextEditor.markdownExportSuccess')) } catch { toast.error(t('richTextEditor.markdownExportError')) } } const handleExportPdf = async () => { const editor = richTextEditorRef?.current?.getEditor() if (!editor) return const title = state.title || note.title || 'Note' toast.loading(t('richTextEditor.pdfExportLoading') || 'Génération du PDF...', { id: 'pdf-export' }) try { const editorEl = document.querySelector('.ProseMirror') as HTMLElement if (!editorEl) throw new Error('Editor not found') // Clone the editor DOM to process it for print const clone = editorEl.cloneNode(true) as HTMLElement // Remove all action buttons, toolbars, and UI elements clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove()) clone.querySelectorAll('[title]').forEach(el => { const title = el.getAttribute('title') if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) { el.remove() } }) // Render KaTeX equations properly const katex = (await import('katex')).default clone.querySelectorAll('.math-equation-block').forEach(el => { const latex = el.getAttribute('data-latex') || el.textContent || '' try { el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false }) } catch {} }) clone.querySelectorAll('.inline-math').forEach(el => { const latex = el.getAttribute('data-latex') || el.textContent || '' try { el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false }) } catch {} }) // Force show all toggle content clone.querySelectorAll('[class*="hidden"]').forEach(el => { (el as HTMLElement).style.display = 'block' }) // Apply callout colors as inline styles (Tailwind classes won't work in print window) clone.querySelectorAll('[data-callout-type]').forEach(el => { const type = el.getAttribute('data-callout-type') const { bg, border } = getCalloutColors(type) const inner = el.querySelector('div') as HTMLElement | null if (inner) { inner.style.background = bg inner.style.borderColor = border } }) const cloneHtml = clone.innerHTML const printWindow = window.open('', '_blank') if (!printWindow) { toast.error(t('richTextEditor.pdfExportBlocked') || 'Popup bloqué', { id: 'pdf-export' }) return } printWindow.document.write(`${title}

${title}

${cloneHtml}`) printWindow.document.close() setTimeout(() => { printWindow.focus() printWindow.print() toast.success(t('richTextEditor.pdfExportSuccess') || 'PDF prêt !', { id: 'pdf-export' }) }, 800) } catch (e: any) { toast.error(e.message || 'Erreur export PDF', { id: 'pdf-export' }) } } // ── Markdown import ─────────────────────────────────────────────────────── const openMarkdownImport = () => { const input = document.createElement('input') input.type = 'file' input.accept = '.md,text/markdown' input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0] if (!file) return const reader = new FileReader() reader.onload = (ev) => { try { const md = ev.target?.result as string const html = markdownToHTML(md) const extractedTitle = extractMarkdownTitle(md) const editor = richTextEditorRef?.current?.getEditor() if (editor) editor.commands.setContent(html) actions.setContent(html) if (extractedTitle) actions.setTitle(extractedTitle) toast.success(t('richTextEditor.markdownImportSuccess')) } catch { toast.error(t('richTextEditor.markdownExportError')) } } reader.readAsText(file) } input.click() } const [generatingExercises, setGeneratingExercises] = useState(false) const [showEduMenu, setShowEduMenu] = useState(false) const publicPageUrl = publishMeta.slug ? `${typeof window !== 'undefined' ? window.location.origin : ''}/p/${publishMeta.slug}` : '' const handlePublishNote = async () => { if (publishLoading) return if (state.isDirty && !state.isSaving) { await actions.handleSaveInPlace() } setPublishLoading(true) try { const res = await fetch('/api/notes/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: note.id, action: 'publish', mode: 'simple' }), }) const data = await res.json() if (res.ok && data.slug) { setPublishMeta({ isPublic: true, slug: data.slug, template: null }) emitNoteChange({ type: 'updated', note: { ...note, isPublic: true, publicSlug: data.slug, publishedTemplate: null }, }) const url = `${window.location.origin}/p/${data.slug}` toast.success(t('richTextEditor.publishSuccess'), { description: url, action: { label: t('richTextEditor.publishLive') || 'Voir', onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') }, }, duration: 8000, }) setPublishOpen(false) } else if (data.error === 'blocked') { toast.error(t('richTextEditor.publishBlocked'), { description: data.reason || undefined, duration: 6000, }) } else { toast.error(data.error || t('general.error')) } } catch { toast.error(t('general.error')) } finally { setPublishLoading(false) } } const publishTemplateLabels: Record = { magazine: t('richTextEditor.publishTemplateMagazine'), brief: t('richTextEditor.publishTemplateBrief'), essay: t('richTextEditor.publishTemplateEssay'), } const handlePublishWithAi = async () => { if (publishLoading || publishEnhanceLocked || (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)) return const consented = await requestAiConsent() if (!consented) return if (state.isDirty && !state.isSaving) { await actions.handleSaveInPlace() } setPublishLoading(true) const toastId = toast.loading(t('richTextEditor.publishWithAiGenerating')) try { const res = await fetch('/api/notes/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: note.id, action: 'publish', mode: 'ai', template: publishTemplate, rewrite: publishRewrite, language, }), }) const data = await res.json() if (res.status === 402) { toast.dismiss(toastId) toast.error( data.errorKey === 'ai.featureLocked' ? t('richTextEditor.publishWithAiLocked') : t('ai.quotaExceeded'), ) return } if (res.ok && data.slug) { setPublishMeta({ isPublic: true, slug: data.slug, template: publishTemplate }) emitNoteChange({ type: 'updated', note: { ...note, isPublic: true, publicSlug: data.slug, publishedTemplate: publishTemplate, }, }) window.dispatchEvent(new Event('ai-usage-changed')) const url = `${window.location.origin}/p/${data.slug}` toast.success(t('richTextEditor.publishAiSuccess'), { id: toastId, description: url, action: { label: t('richTextEditor.publishLive'), onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') }, }, duration: 8000, }) setPublishOpen(false) } else if (data.error === 'blocked') { toast.dismiss(toastId) toast.error(t('richTextEditor.publishBlocked'), { description: data.reason || undefined, duration: 6000, }) } else { toast.dismiss(toastId) toast.error(data.error || t('general.error')) } } catch { toast.dismiss(toastId) toast.error(t('general.error')) } finally { setPublishLoading(false) } } const handleUnpublishNote = async () => { if (publishLoading) return setPublishLoading(true) try { const res = await fetch('/api/notes/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: note.id, action: 'unpublish' }), }) if (res.ok) { setPublishMeta({ isPublic: false, slug: null, template: null }) emitNoteChange({ type: 'updated', note: { ...note, isPublic: false, publicSlug: null, publishedTemplate: null }, }) toast.success(t('richTextEditor.unpublishSuccess')) setPublishOpen(false) } else { const data = await res.json().catch(() => ({})) toast.error(data.error || t('general.error')) } } catch { toast.error(t('general.error')) } finally { setPublishLoading(false) } } const handleCopyPublicLink = async () => { if (!publicPageUrl) return const ok = await copyTextToClipboard(publicPageUrl) if (ok) { setPublishLinkCopied(true) setTimeout(() => setPublishLinkCopied(false), 2000) toast.success(t('richTextEditor.copyPublicLink') || 'Lien copié') } } const handleGenerateExercises = async () => { if (generatingExercises) return const consented = await requestAiConsent() if (!consented) return setGeneratingExercises(true) try { const res = await fetch('/api/ai/generate-exercises', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: note.id, count: 5, language }), }) const data = await res.json() if (!res.ok) { toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur')) } else { toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés !'}`) for (const ex of data.exercises || []) { emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '

' } as any }) } } } catch (e: any) { toast.error(e.message || 'Erreur') } finally { setGeneratingExercises(false) } } const handleConvertToRichtext = async () => { if (isConverting || !state.content.trim()) return setIsConverting(true) const snapshot = { content: state.content, isMarkdown: state.isMarkdown } undoSnapshotRef.current = snapshot try { let html: string if (state.isMarkdown) { const { markdownToHtml } = await import('@/lib/markdown-to-html') html = markdownToHtml(state.content) } else { html = state.content .split(/\n{2,}/) .map(para => `

${para.trim().replace(/\n/g, '
')}

`) .join('') } actions.convertToRichText(html) toast.success(t('notes.convertedToRichText') || 'Converted to rich text', { duration: 8000, action: { label: t('notes.undo') || '↩ Undo', onClick: () => { const snap = undoSnapshotRef.current if (!snap) return actions.setContent(snap.content) if (snap.isMarkdown) actions.setIsMarkdown(true) undoSnapshotRef.current = null toast.info(t('ai.undoApplied') || 'Conversion undone') }, }, }) } catch { toast.error(t('notes.transformFailed') || 'Conversion failed') } finally { setIsConverting(false) } } if (mode === 'fullPage') { const handleCloseWithSave = async () => { if (state.isDirty && !state.isSaving) { await actions.handleSaveInPlace() } onClose() } return (
{state.isSaving ? <>{t('notes.saving')} : state.isDirty ? <>{t('notes.dirtyStatus')} : <>{t('notes.savedStatus')}} {note.historyEnabled && ( )} {state.isMarkdown && !readOnly && ( )} {state.isMarkdown && !readOnly && ( )} {!readOnly && (
{showEduMenu && ( <>
setShowEduMenu(false)} />
)}
)} {!readOnly && voiceSupported && ( )} {!readOnly && onToggleAttachments && ( )} {!readOnly && (
)} {!readOnly && (
{t('richTextEditor.publishTitle')}

{t('richTextEditor.publishDesc')}

{publishMeta.isPublic && publishMeta.slug ? (
{t('richTextEditor.publishLive')} {publishMeta.template && ( {' · '}{publishTemplateLabels[publishMeta.template]} )}
{publicPageUrl}
) : (

{t('richTextEditor.publishSimpleHint')}

{t('richTextEditor.publishWithAi')}

{publishEnhanceLocked ? t('richTextEditor.publishWithAiLocked') : t('richTextEditor.publishWithAiHint').replace( '{count}', String(publishEnhanceRemaining ?? '…'), )}

{/* Sélection template */}
{PUBLISH_TEMPLATES.map((tpl) => ( ))}
{/* Toggle reformulation */}
)} )} {!readOnly && ( )} {!readOnly && ( {t('richTextEditor.exportMarkdown')} {t('richTextEditor.exportPdf') || 'Exporter en PDF'} {t('richTextEditor.importMarkdown')} {t('settings.autoSave') || 'Auto-enregistrement'} {state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')} { try { await deleteNote(note.id, { skipRevalidation: true }) emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId }) toast.success(t('notes.noteDeletedToast')) onClose() } catch { toast.error(t('notes.deleteNoteFailedToast')) } }} className="text-red-600 dark:text-red-400 focus:text-red-600" > {t('notes.deleteNoteConfirmItem')} )} {shareOpen && ( setShareOpen(false)} /> )} setFlashcardsOpen(false)} noteId={note.id} noteTitle={state.title || note.title || 'Untitled'} onSaved={(deckId) => { toast.success(t('flashcards.savedCount', { count: '' }).replace('{count}', ''), { description: t('flashcards.reviewNow') || 'Review now', action: { label: t('flashcards.reviewNow') || 'Review now →', onClick: () => { window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self') }, }, duration: 8000, }) }} />
) } return ( <>
{!readOnly && ( <> {state.isMarkdown && ( )}
{(['small', 'medium', 'large'] as const).map((s) => ( ))}
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
)} {readOnly && (
{t('notes.sharedReadOnly')}
)}
{readOnly ? ( <> ) : ( <> )}
) }