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

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