Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
1141 lines
51 KiB
TypeScript
1141 lines
51 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
import { useNoteEditorContext } from './note-editor-context'
|
|
import { LabelManager } from '@/components/label-manager'
|
|
import { LabelBadge } from '@/components/label-badge'
|
|
import { GhostTags } from '@/components/ghost-tags'
|
|
import { EditorImages } from '@/components/editor-images'
|
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
|
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
|
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink
|
|
} from 'lucide-react'
|
|
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
|
import { NoteShareDialog } from './note-share-dialog'
|
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
|
import { cn } from '@/lib/utils'
|
|
import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
|
|
import { toast } from 'sonner'
|
|
import { format } from 'date-fns'
|
|
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
|
import { getCalloutColors } from '@/lib/editor/callout-colors'
|
|
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
|
import { PUBLISH_TEMPLATES, type PublishTemplateId } from '@/lib/publish/types'
|
|
|
|
interface NoteEditorToolbarProps {
|
|
mode: 'fullPage' | 'dialog'
|
|
onClose: () => void
|
|
onToggleAttachments?: () => void
|
|
attachmentsCount?: number
|
|
}
|
|
|
|
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
|
const { t, language } = useLanguage()
|
|
const { requestAiConsent } = useAiConsent()
|
|
const [isConverting, setIsConverting] = useState(false)
|
|
const [shareOpen, setShareOpen] = useState(false)
|
|
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
|
const [publishOpen, setPublishOpen] = useState(false)
|
|
const [publishLoading, setPublishLoading] = useState(false)
|
|
const [publishMeta, setPublishMeta] = useState({
|
|
isPublic: Boolean(note.isPublic),
|
|
slug: note.publicSlug ?? null,
|
|
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
|
})
|
|
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
|
|
const [publishTemplate, setPublishTemplate] = useState<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,
|
|
})
|
|
|
|
// ── Markdown export ───────────────────────────────────────────────────────
|
|
const handleExportMarkdown = () => {
|
|
try {
|
|
const editor = richTextEditorRef?.current?.getEditor()
|
|
if (!editor) {
|
|
toast.error(t('richTextEditor.markdownExportError'))
|
|
return
|
|
}
|
|
const html = editor.getHTML()
|
|
const title = state.title || note.title || 'note'
|
|
const titleLine = title ? `# ${title}\n\n` : ''
|
|
const markdown = titleLine + tiptapHTMLToMarkdown(html)
|
|
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
toast.success(t('richTextEditor.markdownExportSuccess'))
|
|
} catch {
|
|
toast.error(t('richTextEditor.markdownExportError'))
|
|
}
|
|
}
|
|
|
|
const handleExportPdf = async () => {
|
|
const editor = richTextEditorRef?.current?.getEditor()
|
|
if (!editor) return
|
|
const title = state.title || note.title || 'Note'
|
|
|
|
toast.loading(t('richTextEditor.pdfExportLoading') || 'Génération du PDF...', { id: 'pdf-export' })
|
|
|
|
try {
|
|
const editorEl = document.querySelector('.ProseMirror') as HTMLElement
|
|
if (!editorEl) throw new Error('Editor not found')
|
|
|
|
// Clone the editor DOM to process it for print
|
|
const clone = editorEl.cloneNode(true) as HTMLElement
|
|
|
|
// Remove all action buttons, toolbars, and UI elements
|
|
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
|
|
clone.querySelectorAll('[title]').forEach(el => {
|
|
const title = el.getAttribute('title')
|
|
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
|
|
el.remove()
|
|
}
|
|
})
|
|
|
|
// Render KaTeX equations properly
|
|
const katex = (await import('katex')).default
|
|
clone.querySelectorAll('.math-equation-block').forEach(el => {
|
|
const latex = el.getAttribute('data-latex') || el.textContent || ''
|
|
try {
|
|
el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false })
|
|
} catch {}
|
|
})
|
|
clone.querySelectorAll('.inline-math').forEach(el => {
|
|
const latex = el.getAttribute('data-latex') || el.textContent || ''
|
|
try {
|
|
el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false })
|
|
} catch {}
|
|
})
|
|
|
|
// Force show all toggle content
|
|
clone.querySelectorAll('[class*="hidden"]').forEach(el => {
|
|
(el as HTMLElement).style.display = 'block'
|
|
})
|
|
|
|
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
|
|
clone.querySelectorAll('[data-callout-type]').forEach(el => {
|
|
const type = el.getAttribute('data-callout-type')
|
|
const { bg, border } = getCalloutColors(type)
|
|
const inner = el.querySelector('div') as HTMLElement | null
|
|
if (inner) {
|
|
inner.style.background = bg
|
|
inner.style.borderColor = border
|
|
}
|
|
})
|
|
|
|
const cloneHtml = clone.innerHTML
|
|
|
|
const printWindow = window.open('', '_blank')
|
|
if (!printWindow) {
|
|
toast.error(t('richTextEditor.pdfExportBlocked') || 'Popup bloqué', { id: 'pdf-export' })
|
|
return
|
|
}
|
|
|
|
printWindow.document.write(`<!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.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'),
|
|
}
|
|
|
|
const handlePublishWithAi = async () => {
|
|
if (publishLoading || publishEnhanceLocked || (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)) return
|
|
const consented = await requestAiConsent()
|
|
if (!consented) return
|
|
if (state.isDirty && !state.isSaving) {
|
|
await actions.handleSaveInPlace()
|
|
}
|
|
setPublishLoading(true)
|
|
const toastId = toast.loading(t('richTextEditor.publishWithAiGenerating'))
|
|
try {
|
|
const res = await fetch('/api/notes/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
noteId: note.id,
|
|
action: 'publish',
|
|
mode: 'ai',
|
|
template: publishTemplate,
|
|
rewrite: publishRewrite,
|
|
language,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
if (res.status === 402) {
|
|
toast.dismiss(toastId)
|
|
toast.error(
|
|
data.errorKey === 'ai.featureLocked'
|
|
? t('richTextEditor.publishWithAiLocked')
|
|
: t('ai.quotaExceeded'),
|
|
)
|
|
return
|
|
}
|
|
if (res.ok && data.slug) {
|
|
setPublishMeta({ isPublic: true, slug: data.slug, template: publishTemplate })
|
|
emitNoteChange({
|
|
type: 'updated',
|
|
note: {
|
|
...note,
|
|
isPublic: true,
|
|
publicSlug: data.slug,
|
|
publishedTemplate: publishTemplate,
|
|
},
|
|
})
|
|
window.dispatchEvent(new Event('ai-usage-changed'))
|
|
const url = `${window.location.origin}/p/${data.slug}`
|
|
toast.success(t('richTextEditor.publishAiSuccess'), {
|
|
id: toastId,
|
|
description: url,
|
|
action: {
|
|
label: t('richTextEditor.publishLive'),
|
|
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
|
|
},
|
|
duration: 8000,
|
|
})
|
|
setPublishOpen(false)
|
|
} else if (data.error === 'blocked') {
|
|
toast.dismiss(toastId)
|
|
toast.error(t('richTextEditor.publishBlocked'), {
|
|
description: data.reason || undefined,
|
|
duration: 6000,
|
|
})
|
|
} else {
|
|
toast.dismiss(toastId)
|
|
toast.error(data.error || t('general.error'))
|
|
}
|
|
} catch {
|
|
toast.dismiss(toastId)
|
|
toast.error(t('general.error'))
|
|
} finally {
|
|
setPublishLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleUnpublishNote = async () => {
|
|
if (publishLoading) return
|
|
setPublishLoading(true)
|
|
try {
|
|
const res = await fetch('/api/notes/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId: note.id, action: 'unpublish' }),
|
|
})
|
|
if (res.ok) {
|
|
setPublishMeta({ isPublic: false, slug: null, template: null })
|
|
emitNoteChange({
|
|
type: 'updated',
|
|
note: { ...note, isPublic: false, publicSlug: null, publishedTemplate: null },
|
|
})
|
|
toast.success(t('richTextEditor.unpublishSuccess'))
|
|
setPublishOpen(false)
|
|
} else {
|
|
const data = await res.json().catch(() => ({}))
|
|
toast.error(data.error || t('general.error'))
|
|
}
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
} finally {
|
|
setPublishLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyPublicLink = async () => {
|
|
if (!publicPageUrl) return
|
|
const ok = await copyTextToClipboard(publicPageUrl)
|
|
if (ok) {
|
|
setPublishLinkCopied(true)
|
|
setTimeout(() => setPublishLinkCopied(false), 2000)
|
|
toast.success(t('richTextEditor.copyPublicLink') || 'Lien copié')
|
|
}
|
|
}
|
|
|
|
const handleGenerateExercises = async () => {
|
|
if (generatingExercises) return
|
|
const consented = await requestAiConsent()
|
|
if (!consented) return
|
|
setGeneratingExercises(true)
|
|
try {
|
|
const res = await fetch('/api/ai/generate-exercises', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId: note.id, count: 5, language }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
|
} else {
|
|
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés !'}`)
|
|
for (const ex of data.exercises || []) {
|
|
emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '<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 { 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 && (
|
|
<div className="relative">
|
|
<button
|
|
title={t('flashcards.toolbarGenerate')}
|
|
aria-label={t('flashcards.toolbarGenerate')}
|
|
onClick={() => setShowEduMenu(v => !v)}
|
|
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>
|
|
{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-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
|
<GraduationCap size={16} />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
|
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</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>
|
|
)}
|
|
|
|
{!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 && (
|
|
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
title={t('richTextEditor.publishTitle')}
|
|
aria-label={t('richTextEditor.publishTitle')}
|
|
className={cn(
|
|
'p-1.5 rounded-full border transition-all',
|
|
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>
|
|
|
|
<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">
|
|
{PUBLISH_TEMPLATES.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>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{!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>
|
|
</>
|
|
)
|
|
}
|