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,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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user