feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -839,11 +839,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
void handleSaveInPlaceRef.current()
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}
}
document.addEventListener('keydown', handler)

View File

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

View File

@@ -1,10 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
interface PublishDialogProps {
@@ -25,8 +25,6 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
if (!open) return null
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
const handlePublish = async () => {
@@ -41,16 +39,10 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
if (res.ok && data.slug) {
setIsPublic(true)
setSlug(data.slug)
if (data.moderation === 'flagged') {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !', {
description: '⚠️ Un modérateur examinera le contenu sous peu.',
})
} else {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
}
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
} else if (data.error === 'blocked') {
toast.error(t('richTextEditor.publishBlocked') || 'Publication refusée', {
description: data.reason || 'Le contenu ne respecte pas les règles de publication.',
description: data.reason || 'Le contenu ne respecte pas les règles.',
duration: 6000,
})
} else {
@@ -82,57 +74,70 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
}
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" dir="auto" onClick={onClose}>
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 flex items-center justify-center">
<Globe size={15} className="text-brand-accent" />
</div>
<h3 className="text-sm font-semibold">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground"><X size={16} /></button>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-400">
<X size={16} />
</button>
</div>
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
</p>
{/* Body */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4 leading-relaxed">
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
</p>
{isPublic && slug ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2.5 rounded-xl 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') || 'En ligne'}</span>
</div>
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-border bg-background">
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicUrl}</span>
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{isPublic && slug ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2.5 rounded-xl 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') || 'En ligne'}</span>
</div>
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800">
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate px-1">{publicUrl}</span>
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
<ExternalLink size={14} />
</a>
</div>
<button
onClick={handleUnpublish}
disabled={loading}
className="w-full py-2 rounded-xl 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 transition-colors disabled:opacity-40"
>
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
</button>
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
<ExternalLink size={14} />
</a>
</div>
) : (
<button
onClick={handleUnpublish}
onClick={handlePublish}
disabled={loading}
className="w-full py-2 rounded-xl 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 transition-colors disabled:opacity-40"
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
>
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
{t('richTextEditor.publish') || 'Publier'}
</button>
</div>
) : (
<button
onClick={handlePublish}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
{t('richTextEditor.publish') || 'Publier'}
</button>
)}
)}
</div>
</div>
</div>
</div>,
document.body
)
}