feat: publication IA (magazine/brief/essay) + fixes critique
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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
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'
|
||||
@@ -19,11 +19,10 @@ 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
|
||||
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 { PublishDialog } from './publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -33,6 +32,10 @@ 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'
|
||||
@@ -44,12 +47,64 @@ interface NoteEditorToolbarProps {
|
||||
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 ──────────────────────────────────────────────────
|
||||
@@ -137,18 +192,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
// 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')
|
||||
const { bg, border } = getCalloutColors(type)
|
||||
const inner = el.querySelector('div') as HTMLElement | null
|
||||
if (inner) {
|
||||
(inner as HTMLElement).style.background = c.bg
|
||||
(inner as HTMLElement).style.borderColor = c.border
|
||||
inner.style.background = bg
|
||||
inner.style.borderColor = border
|
||||
}
|
||||
})
|
||||
|
||||
@@ -239,8 +287,176 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
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', {
|
||||
@@ -252,13 +468,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
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 dans ce carnet !'}`, {
|
||||
action: {
|
||||
label: t('richTextEditor.seeExercises') || 'Voir',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
})
|
||||
// Emit events so the note list refreshes
|
||||
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 })
|
||||
}
|
||||
@@ -499,18 +709,191 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={t('richTextEditor.publishTitle') || 'Publication publique'}
|
||||
onClick={() => setPublishOpen(true)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-full border transition-all",
|
||||
note.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>
|
||||
<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 && (
|
||||
@@ -752,16 +1135,6 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{publishOpen && (
|
||||
<PublishDialog
|
||||
open={publishOpen}
|
||||
onClose={() => setPublishOpen(false)}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title || 'Untitled'}
|
||||
isPublic={note.isPublic}
|
||||
publicSlug={note.publicSlug ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user