Files
Momento/memento-note/components/note-editor/note-editor-toolbar.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
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.
2026-09-05 20:53:48 +00:00

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>
</>
)
}