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
144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
'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 { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
|
|
|
interface PublishDialogProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
noteId: string
|
|
noteTitle: string
|
|
isPublic: boolean
|
|
publicSlug: string | null
|
|
}
|
|
|
|
export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: initialPublic, publicSlug }: PublishDialogProps) {
|
|
const { t } = useLanguage()
|
|
const [isPublic, setIsPublic] = useState(initialPublic)
|
|
const [slug, setSlug] = useState(publicSlug)
|
|
const [loading, setLoading] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
|
|
|
|
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
|
|
|
|
const handlePublish = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/notes/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId, action: 'publish' }),
|
|
})
|
|
const data = await res.json()
|
|
if (res.ok && data.slug) {
|
|
setIsPublic(true)
|
|
setSlug(data.slug)
|
|
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.',
|
|
duration: 6000,
|
|
})
|
|
} else {
|
|
toast.error(data.error || 'Erreur')
|
|
}
|
|
} catch { toast.error('Erreur') }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const handleUnpublish = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/notes/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId, action: 'unpublish' }),
|
|
})
|
|
if (res.ok) {
|
|
setIsPublic(false)
|
|
setSlug(null)
|
|
toast.success(t('richTextEditor.unpublishSuccess') || 'Note dépubliée')
|
|
} else { toast.error('Erreur') }
|
|
} catch { toast.error('Erreur') }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const copyLink = async () => {
|
|
const ok = await copyTextToClipboard(publicUrl)
|
|
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
|
|
}
|
|
|
|
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 text-gray-900 dark:text-white">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
|
|
</div>
|
|
<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>
|
|
|
|
{/* 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-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>
|
|
</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>,
|
|
document.body
|
|
)
|
|
}
|