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.
1320 lines
60 KiB
TypeScript
1320 lines
60 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
import { usePathname, useSearchParams } from 'next/navigation'
|
|
import { editorBackLabelKey, resolveEditorBackKind } from '@/lib/editor/editor-back-context'
|
|
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, Clapperboard
|
|
} from 'lucide-react'
|
|
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
|
import { NoteShareDialog } from './note-share-dialog'
|
|
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
|
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
|
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
|
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
|
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 { 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 searchParams = useSearchParams()
|
|
const pathname = usePathname()
|
|
const backKind = resolveEditorBackKind(pathname, searchParams)
|
|
const backLabel = t(editorBackLabelKey(backKind))
|
|
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 [interactivePageOpen, setInteractivePageOpen] = useState(false)
|
|
const [interactivePageContent, setInteractivePageContent] = useState('')
|
|
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 [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
|
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
|
|
const [publishRewrite, setPublishRewrite] = useState(false)
|
|
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(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,
|
|
onError: (message) => {
|
|
toast.error(message)
|
|
},
|
|
})
|
|
|
|
// ── 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())
|
|
// Remove remaining UI controls (block action icons with tooltips) regardless of
|
|
// UI language — keep user content that may carry a title (links, abbreviations).
|
|
clone.querySelectorAll('[title]').forEach(el => {
|
|
const tag = el.tagName.toLowerCase()
|
|
if (tag !== 'a' && tag !== 'abbr') 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(`<!DOCTYPE html><html dir="auto"><head><meta charset="utf-8"><title>${title}</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
<style>
|
|
@page { margin: 1.5cm; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 720px; margin: 0 auto; line-height: 1.7; color: #1a1a1a; font-size: 14px; }
|
|
h1 { font-size: 1.8em; border-bottom: 2px solid #e5e5e5; padding-bottom: 0.3em; margin-bottom: 0.5em; }
|
|
h2 { font-size: 1.4em; margin-top: 1.5em; }
|
|
h3 { font-size: 1.2em; margin-top: 1.2em; }
|
|
p { margin: 0.6em 0; }
|
|
ul, ol { padding-left: 1.5em; }
|
|
li { margin: 0.3em 0; }
|
|
blockquote { border-left: 3px solid #d4d4d4; padding-left: 1em; color: #555; font-style: italic; margin: 0.8em 0; }
|
|
pre { background: #f5f5f5; padding: 0.8em; border-radius: 6px; overflow-x: auto; font-size: 13px; }
|
|
code { font-family: 'SF Mono', Menlo, monospace; }
|
|
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
|
th, td { border: 1px solid #ddd; padding: 0.5em 0.8em; text-align: left; }
|
|
th { background: #f5f5f5; font-weight: 600; }
|
|
img { max-width: 100%; height: auto; border-radius: 4px; }
|
|
.callout-block { padding: 0.8em 1em; border-radius: 8px; border-left: 4px solid; margin: 0.8em 0; }
|
|
.callout-block[data-callout-type="info"] { background: #eff6ff; border-color: #3b82f6; }
|
|
.callout-block[data-callout-type="warning"] { background: #fffbeb; border-color: #f59e0b; }
|
|
.callout-block[data-callout-type="tip"] { background: #faf5ff; border-color: #8b5cf6; }
|
|
.callout-block[data-callout-type="success"] { background: #f0fdf4; border-color: #22c55e; }
|
|
.callout-block[data-callout-type="danger"] { background: #fef2f2; border-color: #ef4444; }
|
|
.toggle-block { border: 1px solid #e5e5e5; border-radius: 8px; margin: 0.8em 0; overflow: hidden; }
|
|
.toggle-block > div:first-child { background: #f9f9f9; padding: 0.5em 0.8em; font-weight: 600; font-size: 0.85em; }
|
|
.toggle-content { padding: 0.5em 0.8em; }
|
|
.columns-block { display: flex; gap: 16px; margin: 0.8em 0; }
|
|
.columns-block > div { flex: 1; }
|
|
.math-equation-block { margin: 1em 0; text-align: center; }
|
|
.outline-block { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.8em; margin: 1em 0; }
|
|
.link-preview-block { border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; margin: 0.8em 0; }
|
|
.link-preview-searchable { display: none !important; }
|
|
a { color: #A47148; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
@media print { body { margin: 0; max-width: 100%; } }
|
|
</style></head><body><h1>${title}</h1>${cloneHtml}</body></html>`)
|
|
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.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
|
|
}
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
} finally {
|
|
setPublishLoading(false)
|
|
}
|
|
}
|
|
|
|
const publishTemplateLabels: Record<PublishTemplateId, string> = {
|
|
magazine: t('richTextEditor.publishTemplateMagazine'),
|
|
brief: t('richTextEditor.publishTemplateBrief'),
|
|
essay: t('richTextEditor.publishTemplateEssay'),
|
|
'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
|
|
}
|
|
|
|
const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
|
|
(tpl) => tpl !== 'interactive-page'
|
|
)
|
|
|
|
const handlePublishInteractivePage = async () => {
|
|
if (publishLoading) return
|
|
setPublishLoading(true)
|
|
try {
|
|
const consented = await requestAiConsent()
|
|
if (!consented) return
|
|
if (state.isDirty && !state.isSaving) {
|
|
await actions.handleSaveInPlace()
|
|
}
|
|
const html =
|
|
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
|
|
state.content ||
|
|
note.content ||
|
|
''
|
|
setInteractivePageContent(html)
|
|
setPublishOpen(false)
|
|
setInteractivePageOpen(true)
|
|
} finally {
|
|
setPublishLoading(false)
|
|
}
|
|
}
|
|
|
|
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.errorKey ? t(data.errorKey) : (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.errorKey ? t(data.errorKey) : (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: '<p></p>' } 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 => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
|
.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.isSaving) return
|
|
if (state.isDirty) {
|
|
const saved = await actions.handleSaveInPlace()
|
|
if (!saved) return
|
|
}
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto w-full px-3 sm:px-8 md:px-12 py-3 sm:py-6 md:py-8 flex items-center justify-between gap-2 sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseWithSave}
|
|
title={backLabel}
|
|
aria-label={backLabel}
|
|
className="flex h-11 min-w-11 shrink-0 items-center gap-2 px-1.5 md:px-0 text-foreground hover:opacity-60 transition-opacity rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
|
|
>
|
|
<ArrowLeft size={18} className="rtl:rotate-180 shrink-0" />
|
|
<span className="hidden md:inline text-sm font-medium whitespace-nowrap">{backLabel}</span>
|
|
<span className="md:hidden text-sm font-medium whitespace-nowrap">{t('general.back')}</span>
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1 sm:gap-1.5 md:gap-2 min-w-0">
|
|
{/* 1. Status */}
|
|
<span
|
|
className="flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1 min-h-11 md:min-h-0"
|
|
title={state.isSaving ? t('notes.saving') : state.isDirty ? t('notes.dirtyStatus') : t('notes.savedStatus')}
|
|
aria-label={state.isSaving ? t('notes.saving') : state.isDirty ? t('notes.dirtyStatus') : t('notes.savedStatus')}
|
|
>
|
|
{state.isSaving
|
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span className="hidden sm:inline">{t('notes.saving')}</span></>
|
|
: state.isDirty
|
|
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span className="hidden sm:inline">{t('notes.dirtyStatus')}</span></>
|
|
: <><Check className="h-3 w-3 text-emerald-500" /><span className="hidden sm:inline">{t('notes.savedStatus')}</span></>}
|
|
</span>
|
|
|
|
{note.historyEnabled && (
|
|
<span
|
|
className="hidden sm:flex items-center gap-1 text-[11px] text-foreground/35 select-none cursor-help"
|
|
title={t('notes.historyEnabledTooltip') || 'Version history enabled'}
|
|
>
|
|
<History className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
|
|
{/* 2. Save (primary) */}
|
|
{!readOnly && (
|
|
<button
|
|
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
|
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
|
onClick={() => actions.handleSaveInPlace()}
|
|
disabled={state.isSaving || !state.isDirty}
|
|
className={cn(
|
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
|
|
state.isDirty
|
|
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
|
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
</button>
|
|
)}
|
|
|
|
{/* 3. Publish */}
|
|
{!readOnly && (
|
|
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
title={t('richTextEditor.publishTitle')}
|
|
aria-label={t('richTextEditor.publishTitle')}
|
|
className={cn(
|
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all md:h-auto md:w-auto md:p-1.5',
|
|
publishMeta.isPublic
|
|
? 'border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20'
|
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
|
)}
|
|
>
|
|
<Globe size={16} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 rounded-xl p-4 shadow-xl">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
|
|
<Globe size={13} className="text-brand-accent" />
|
|
</div>
|
|
<span className="text-sm font-semibold">{t('richTextEditor.publishTitle')}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
|
{t('richTextEditor.publishDesc')}
|
|
</p>
|
|
{publishMeta.isPublic && publishMeta.slug ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
|
<Check size={14} className="text-green-500 shrink-0" />
|
|
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
|
{t('richTextEditor.publishLive')}
|
|
{publishMeta.template && (
|
|
<span className="text-green-600/80 dark:text-green-400/80">
|
|
{' · '}{publishTemplateLabels[publishMeta.template]}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 p-2 rounded-lg border border-border bg-muted/40">
|
|
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicPageUrl}</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopyPublicLink}
|
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
aria-label={t('richTextEditor.copyPublicLink')}
|
|
>
|
|
{publishLinkCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
|
</button>
|
|
<a
|
|
href={publicPageUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
aria-label={t('richTextEditor.openPublicPage')}
|
|
>
|
|
<ExternalLink size={14} />
|
|
</a>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleUnpublishNote}
|
|
disabled={publishLoading}
|
|
className="w-full py-2 rounded-lg border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-40 transition-colors"
|
|
>
|
|
{publishLoading ? <Loader2 size={14} className="animate-spin mx-auto" /> : t('richTextEditor.unpublish')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<button
|
|
type="button"
|
|
onClick={handlePublishNote}
|
|
disabled={publishLoading}
|
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-border bg-background text-sm font-medium hover:bg-muted disabled:opacity-40 transition-colors"
|
|
>
|
|
{publishLoading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
|
{t('richTextEditor.publishSimple')}
|
|
</button>
|
|
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
|
{t('richTextEditor.publishSimpleHint')}
|
|
</p>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handlePublishInteractivePage}
|
|
disabled={publishLoading}
|
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
|
|
>
|
|
{publishLoading ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Clapperboard size={14} />
|
|
)}
|
|
{t('richTextEditor.publishInteractivePage') ||
|
|
'Page interactive'}
|
|
</button>
|
|
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
|
{t('richTextEditor.publishInteractivePageHint') ||
|
|
'Hero, sections, démos Play/Step — 20 crédits'}
|
|
</p>
|
|
|
|
<div className="border-t border-border/50 pt-3 space-y-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Sparkles size={13} className="text-brand-accent shrink-0" />
|
|
<span className="text-xs font-semibold">{t('richTextEditor.publishWithAi')}</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
{publishEnhanceLocked
|
|
? t('richTextEditor.publishWithAiLocked')
|
|
: t('richTextEditor.publishWithAiHint').replace(
|
|
'{count}',
|
|
String(publishEnhanceRemaining ?? '…'),
|
|
)}
|
|
</p>
|
|
{/* Sélection template */}
|
|
<div className="space-y-1">
|
|
{classicPublishTemplates.map((tpl) => (
|
|
<label
|
|
key={tpl}
|
|
className={cn(
|
|
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer text-xs transition-colors',
|
|
publishTemplate === tpl
|
|
? 'bg-brand-accent/10 text-brand-accent font-medium'
|
|
: 'hover:bg-muted text-foreground',
|
|
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="publish-template"
|
|
value={tpl}
|
|
checked={publishTemplate === tpl}
|
|
onChange={() => setPublishTemplate(tpl)}
|
|
className="accent-[var(--color-brand-accent)]"
|
|
/>
|
|
{publishTemplateLabels[tpl]}
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* Toggle reformulation */}
|
|
<div
|
|
className={cn(
|
|
'rounded-xl border p-3 transition-colors',
|
|
publishRewrite
|
|
? 'border-brand-accent/40 bg-brand-accent/5'
|
|
: 'border-border bg-muted/30',
|
|
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
|
)}
|
|
>
|
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
|
<div className="relative mt-0.5 shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={publishRewrite}
|
|
onChange={e => setPublishRewrite(e.target.checked)}
|
|
className="sr-only"
|
|
/>
|
|
<div className={cn(
|
|
'w-8 h-4 rounded-full transition-colors',
|
|
publishRewrite ? 'bg-brand-accent' : 'bg-muted-foreground/30',
|
|
)}>
|
|
<div className={cn(
|
|
'w-3 h-3 rounded-full bg-white shadow transition-transform mt-0.5',
|
|
publishRewrite ? 'translate-x-4' : 'translate-x-0.5',
|
|
)} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold leading-tight">
|
|
{t('richTextEditor.publishRewriteLabel')}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
|
|
{publishRewrite
|
|
? t('richTextEditor.publishRewriteOnHint')
|
|
: t('richTextEditor.publishRewriteOffHint')}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handlePublishWithAi}
|
|
disabled={
|
|
publishLoading
|
|
|| publishEnhanceLocked
|
|
|| (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)
|
|
}
|
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 disabled:opacity-40 transition-colors"
|
|
>
|
|
{publishLoading ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Sparkles size={14} />
|
|
)}
|
|
{t('richTextEditor.publishWithAi')}
|
|
</button>
|
|
<p className="text-[10px] text-muted-foreground text-center">
|
|
{t('richTextEditor.publishWithAiCostHint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{/* 4. Share */}
|
|
{!readOnly && (
|
|
<button
|
|
title={t('notes.shareNoteTitle')}
|
|
aria-label={t('notes.shareNoteAria')}
|
|
onClick={() => setShareOpen(true)}
|
|
className="hidden min-[360px]:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
|
|
>
|
|
<Share2 size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 5. AI assistant */}
|
|
<button
|
|
title={t('ai.openAssistant')}
|
|
aria-label={t('ai.openAssistant')}
|
|
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
|
className={cn(
|
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
|
|
state.aiOpen
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
<Sparkles size={16} />
|
|
</button>
|
|
|
|
{/* 6. Brainstorm */}
|
|
<button
|
|
title={t('notes.brainstormThisIdea')}
|
|
aria-label={t('notes.brainstormThisIdeaAria')}
|
|
onClick={() => {
|
|
const title = note.title || ''
|
|
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
|
const seed = title ? `${title}. ${summary}` : summary
|
|
if (!seed.trim()) return
|
|
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
|
}}
|
|
className="hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all md:h-auto md:w-auto md:p-1.5"
|
|
>
|
|
<Wind size={16} />
|
|
</button>
|
|
|
|
{/* 7. Education (flashcards / exercises) */}
|
|
{!readOnly && (
|
|
<div className="relative hidden md:block">
|
|
<button
|
|
title={t('flashcards.toolbarGenerate')}
|
|
aria-label={t('flashcards.toolbarGenerate')}
|
|
onClick={() => setShowEduMenu(v => !v)}
|
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
|
|
>
|
|
<GraduationCap size={16} />
|
|
</button>
|
|
{showEduMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
|
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
|
<button
|
|
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
|
>
|
|
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
|
<GraduationCap size={16} />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
|
<div className="text-sm text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Elles reviennent au bon moment'}</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
|
disabled={generatingExercises}
|
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
|
>
|
|
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
|
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
|
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 8. Voice / attachments / markdown tools */}
|
|
{!readOnly && voiceSupported && (
|
|
<button
|
|
title={voiceState === 'listening'
|
|
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
|
: (t('editor.voiceStart') || 'Dicter du texte')}
|
|
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
|
onClick={toggleVoice}
|
|
className={cn(
|
|
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all md:h-auto md:w-auto md:p-1.5',
|
|
voiceState === 'listening'
|
|
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
|
)}
|
|
>
|
|
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && onToggleAttachments && (
|
|
<button
|
|
title={t('notes.attachments') || 'Attachments'}
|
|
aria-label={t('notes.attachments') || 'Attachments'}
|
|
onClick={onToggleAttachments}
|
|
className="relative hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5"
|
|
>
|
|
<Paperclip size={16} />
|
|
{(attachmentsCount ?? 0) > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
|
{attachmentsCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{state.isMarkdown && !readOnly && (
|
|
<button
|
|
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
|
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
|
className={cn(
|
|
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
|
|
state.showMarkdownPreview
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
<Eye size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{state.isMarkdown && !readOnly && (
|
|
<button
|
|
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
|
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
|
onClick={handleConvertToRichtext}
|
|
disabled={isConverting}
|
|
className={cn(
|
|
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
|
|
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
|
isConverting && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
|
</button>
|
|
)}
|
|
|
|
{/* 9. Overflow menu — always on mobile (secondary actions), desktop when editable */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={t('notes.optionsMenuAria')}
|
|
className={cn(
|
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all md:h-auto md:w-auto md:p-1.5',
|
|
readOnly && 'md:hidden',
|
|
)}
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
{!readOnly && (
|
|
<DropdownMenuItem className="min-[360px]:hidden" onClick={() => setShareOpen(true)}>
|
|
<Share2 className="h-4 w-4 me-2" />
|
|
{t('notes.shareNoteTitle')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="md:hidden"
|
|
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
|
>
|
|
<PanelRight className="h-4 w-4 me-2" />
|
|
{t('notes.documentInfoAria')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="md:hidden"
|
|
onClick={() => {
|
|
const title = note.title || ''
|
|
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
|
const seed = title ? `${title}. ${summary}` : summary
|
|
if (!seed.trim()) return
|
|
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
|
}}
|
|
>
|
|
<Wind className="h-4 w-4 me-2" />
|
|
{t('notes.brainstormThisIdea')}
|
|
</DropdownMenuItem>
|
|
{!readOnly && (
|
|
<>
|
|
<DropdownMenuItem className="md:hidden" onClick={() => setFlashcardsOpen(true)}>
|
|
<GraduationCap className="h-4 w-4 me-2" />
|
|
{t('flashcards.toolbarGenerate')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="md:hidden" onClick={() => void handleGenerateExercises()}>
|
|
<PenTool className="h-4 w-4 me-2" />
|
|
{t('richTextEditor.generateExercises') || 'Générer des exercices'}
|
|
</DropdownMenuItem>
|
|
{voiceSupported && (
|
|
<DropdownMenuItem className="md:hidden" onClick={toggleVoice}>
|
|
{voiceState === 'listening' ? <MicOff className="h-4 w-4 me-2" /> : <Mic className="h-4 w-4 me-2" />}
|
|
{voiceState === 'listening'
|
|
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
|
: (t('editor.voiceStart') || 'Dicter du texte')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{onToggleAttachments && (
|
|
<DropdownMenuItem className="md:hidden" onClick={onToggleAttachments}>
|
|
<Paperclip className="h-4 w-4 me-2" />
|
|
{t('notes.attachments') || 'Attachments'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{state.isMarkdown && (
|
|
<>
|
|
<DropdownMenuItem className="md:hidden" onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}>
|
|
<Eye className="h-4 w-4 me-2" />
|
|
{state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="md:hidden" onClick={() => void handleConvertToRichtext()}>
|
|
<Wand2 className="h-4 w-4 me-2" />
|
|
{t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
<DropdownMenuSeparator className="md:hidden" />
|
|
<DropdownMenuItem onClick={handleExportMarkdown}>
|
|
<FileDown className="h-4 w-4 me-2" />
|
|
{t('richTextEditor.exportMarkdown')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleExportPdf}>
|
|
<Printer className="h-4 w-4 me-2" />
|
|
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={openMarkdownImport}>
|
|
<FileUp className="h-4 w-4 me-2" />
|
|
{t('richTextEditor.importMarkdown')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
|
|
<span className="flex items-center">
|
|
<Save className="h-4 w-4 me-2 text-muted-foreground" />
|
|
{t('settings.autoSave') || 'Auto-enregistrement'}
|
|
</span>
|
|
<span className={cn(
|
|
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
|
|
state.autoSaveEnabled
|
|
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
|
|
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
|
|
)}>
|
|
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setConfirmDeleteOpen(true)}
|
|
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
|
>
|
|
<Trash2 className="h-4 w-4 me-2" />
|
|
{t('notes.deleteNoteConfirmItem')}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<ConfirmDeleteNoteDialog
|
|
open={confirmDeleteOpen}
|
|
onOpenChange={setConfirmDeleteOpen}
|
|
onConfirm={async () => {
|
|
try {
|
|
await deleteNote(note.id, { skipRevalidation: true })
|
|
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
|
showNoteTrashedToast(note, t)
|
|
onClose()
|
|
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
|
}}
|
|
/>
|
|
|
|
{shareOpen && (
|
|
<NoteShareDialog
|
|
noteId={note.id}
|
|
noteTitle={state.title}
|
|
onClose={() => setShareOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<FlashcardGenerateDialog
|
|
open={flashcardsOpen}
|
|
onClose={() => 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,
|
|
})
|
|
}}
|
|
/>
|
|
|
|
<InteractivePagePublishDialog
|
|
open={interactivePageOpen}
|
|
onOpenChange={setInteractivePageOpen}
|
|
noteId={note.id}
|
|
content={interactivePageContent}
|
|
language={language}
|
|
onPublished={(slug) => {
|
|
setPublishMeta({
|
|
isPublic: true,
|
|
slug,
|
|
template: 'interactive-page',
|
|
})
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
aria-label={t('notes.documentInfoAria')}
|
|
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
|
className={cn(
|
|
'hidden md:flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-all duration-300 md:h-auto md:w-auto md:p-1.5',
|
|
state.infoOpen
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
<PanelRight size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
|
<div className="flex items-center gap-0.5">
|
|
{!readOnly && (
|
|
<>
|
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
|
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
|
<Bell className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{state.isMarkdown && (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
|
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
|
onClick={() => actions.setAiOpen(!state.aiOpen)}
|
|
title={t('ai.aiNoteTitle')}
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
|
|
</Button>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="flex flex-col gap-1 p-1">
|
|
{(['small', 'medium', 'large'] as const).map((s) => (
|
|
<Button key={s} variant="ghost" size="sm"
|
|
onClick={() => actions.setSize(s)}
|
|
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
|
|
{s}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
|
<Palette className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="grid grid-cols-5 gap-2 p-2">
|
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
|
<button key={colorName}
|
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
|
|
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
|
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
|
</>
|
|
)}
|
|
{readOnly && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{readOnly ? (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
onClick={actions.handleMakeCopy}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
{t('notes.makeCopy')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
|
onClick={async () => {
|
|
try {
|
|
await leaveSharedNote(note.id, { skipRevalidation: true })
|
|
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
|
toast.success(t('notes.leftShare'))
|
|
onClose()
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{t('notes.leaveShare')}
|
|
</Button>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.close')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={() => actions.handleSave()} disabled={state.isSaving}>
|
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|