Files
Momento/memento-note/components/note-editor/note-editor-toolbar.tsx
Antigravity 08d190eb03
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m6s
CI / Deploy production (on server) (push) Has been skipped
fix: exercices dans panneau IA + erreur language
- Générateur d'exercices déplacé du menu ⋯ vers le panneau IA (onglet Actions > Outils de génération)
- Même design que les cartes slides/diagrammes
- Fix: import useLanguage supprimé de la route API (hook client en serveur)
- i18n FR/EN
2026-06-14 20:02:19 +00:00

698 lines
31 KiB
TypeScript

'use client'
import { useState, useRef, useCallback } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
import { GhostTags } from '@/components/ghost-tags'
import { EditorImages } from '@/components/editor-images'
import { TitleSuggestions } from '@/components/title-suggestions'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
import { toast } from 'sonner'
import { format } from 'date-fns'
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
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 } = useLanguage()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
// ── Voice transcription ──────────────────────────────────────────────────
const handleTranscript = useCallback((text: string) => {
const editor = richTextEditorRef?.current?.getEditor()
if (editor) {
editor.chain().focus().insertContent(' ' + text).run()
}
}, [richTextEditorRef])
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
onTranscript: handleTranscript,
})
// ── Markdown export ───────────────────────────────────────────────────────
const handleExportMarkdown = () => {
try {
const editor = richTextEditorRef?.current?.getEditor()
if (!editor) {
toast.error(t('richTextEditor.markdownExportError'))
return
}
const html = editor.getHTML()
const title = state.title || note.title || 'note'
const titleLine = title ? `# ${title}\n\n` : ''
const markdown = titleLine + tiptapHTMLToMarkdown(html)
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(t('richTextEditor.markdownExportSuccess'))
} catch {
toast.error(t('richTextEditor.markdownExportError'))
}
}
const handleExportPdf = async () => {
const editor = richTextEditorRef?.current?.getEditor()
if (!editor) return
const title = state.title || note.title || 'Note'
toast.loading(t('richTextEditor.pdfExportLoading') || 'Génération du PDF...', { id: 'pdf-export' })
try {
const editorEl = document.querySelector('.ProseMirror') as HTMLElement
if (!editorEl) throw new Error('Editor not found')
// Clone the editor DOM to process it for print
const clone = editorEl.cloneNode(true) as HTMLElement
// Remove all action buttons, toolbars, and UI elements
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
clone.querySelectorAll('[title]').forEach(el => {
const title = el.getAttribute('title')
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
el.remove()
}
})
// Render KaTeX equations properly
const katex = (await import('katex')).default
clone.querySelectorAll('.math-equation-block').forEach(el => {
const latex = el.getAttribute('data-latex') || el.textContent || ''
try {
el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false })
} catch {}
})
clone.querySelectorAll('.inline-math').forEach(el => {
const latex = el.getAttribute('data-latex') || el.textContent || ''
try {
el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false })
} catch {}
})
// Force show all toggle content
clone.querySelectorAll('[class*="hidden"]').forEach(el => {
(el as HTMLElement).style.display = 'block'
})
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
clone.querySelectorAll('[data-callout-type]').forEach(el => {
const type = el.getAttribute('data-callout-type')
const colors: Record<string, { bg: string; border: string }> = {
info: { bg: '#eff6ff', border: '#93c5fd' },
warning: { bg: '#fffbeb', border: '#fcd34d' },
tip: { bg: '#faf5ff', border: '#c4b5fd' },
success: { bg: '#f0fdf4', border: '#86efac' },
danger: { bg: '#fef2f2', border: '#fca5a5' },
}
const c = colors[type || 'info'] || colors.info
const inner = el.querySelector('div')
if (inner) {
(inner as HTMLElement).style.background = c.bg
(inner as HTMLElement).style.borderColor = c.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 handleGenerateExercises = async () => {
if (generatingExercises) 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(t('richTextEditor.exercisesGenerated') || `${data.exercises?.length || 0} exercices créés !`)
}
} 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 { marked } = await import('marked')
html = await marked(state.content, { async: false }) as string
} else {
html = state.content
.split(/\n{2,}/)
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.join('')
}
actions.setContent(html)
actions.setIsMarkdown(false)
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
duration: 8000,
action: {
label: t('notes.undo') || '↩ Undo',
onClick: () => {
const snap = undoSnapshotRef.current
if (!snap) return
actions.setContent(snap.content)
if (snap.isMarkdown) actions.setIsMarkdown(true)
undoSnapshotRef.current = null
toast.info(t('ai.undoApplied') || 'Conversion undone')
},
},
})
} catch {
toast.error(t('notes.transformFailed') || 'Conversion failed')
} finally {
setIsConverting(false)
}
}
if (mode === 'fullPage') {
const handleCloseWithSave = async () => {
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
onClose()
}
return (
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<button
onClick={handleCloseWithSave}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button>
<div className="flex items-center gap-4">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
</span>
{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(
'p-1.5 rounded-full border transition-all duration-300',
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(
'p-1.5 rounded-full border transition-all duration-300',
'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>
)}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
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>
<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="p-1.5 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"
>
<Wind size={16} />
</button>
{!readOnly && (
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setFlashcardsOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
)}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
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 p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<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>
)}
{!readOnly && (
<div className="flex items-center gap-1.5">
<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(
'p-1.5 rounded-full border transition-all duration-300',
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>
</div>
)}
{!readOnly && (
<button
title={t('notes.shareNoteTitle')}
aria-label={t('notes.shareNoteAria')}
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Share2 size={16} />
</button>
)}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label={t('notes.optionsMenuAria')} className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<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={async () => {
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.noteDeletedToast'))
onClose()
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 me-2" />
{t('notes.deleteNoteConfirmItem')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{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,
})
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
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>
{/* Hidden file input for Markdown import — remplacé par création dynamique dans openMarkdownImport */}
</>
)
}