feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clapperboard, Loader2, Globe } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import {
|
||||
generateInteractivePage,
|
||||
generateInteractivePagePlan,
|
||||
generateInteractivePageSection,
|
||||
type PagePlan,
|
||||
} from '@/lib/ai/services/interactive-page-client.service'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSection,
|
||||
type PageSpecV1,
|
||||
} from '@/lib/interactive-page'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
|
||||
|
||||
function slugId(title: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : 'page.generated'
|
||||
}
|
||||
|
||||
/** Degraded section when its LLM call failed — page still completes. */
|
||||
function fallbackSection(
|
||||
sectionId: string,
|
||||
plan: PagePlan['sections'][number],
|
||||
lang: string
|
||||
): PageSection {
|
||||
const fr = lang.startsWith('fr')
|
||||
return {
|
||||
id: sectionId,
|
||||
title: plan.title,
|
||||
blocks: [
|
||||
{ type: 'prose', md: plan.goal },
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'note',
|
||||
title: fr ? 'En bref' : 'In short',
|
||||
md: plan.demoGoal || plan.goal,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assemblePage(
|
||||
plan: PagePlan,
|
||||
sections: PageSection[],
|
||||
lang: string
|
||||
): PageSpecV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: slugId(plan.heroTitle),
|
||||
lang,
|
||||
hero: {
|
||||
kicker: lang.startsWith('fr')
|
||||
? 'EXPLAINER INTERACTIF'
|
||||
: 'INTERACTIVE EXPLAINER',
|
||||
title: plan.heroTitle,
|
||||
subtitle: plan.heroSubtitle,
|
||||
meta: lang.startsWith('fr')
|
||||
? 'Généré depuis votre note'
|
||||
: 'Generated from your note',
|
||||
},
|
||||
overview: {
|
||||
lead: plan.overviewLead,
|
||||
cards: plan.overviewCards.map((c) => ({
|
||||
badge: c.badge,
|
||||
title: c.title,
|
||||
body: c.body,
|
||||
|
||||
intent: c.intent as any,
|
||||
})),
|
||||
},
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
|
||||
* → validate → preview → publish (pageSpec snapshot, no double quota).
|
||||
* Falls back to the deterministic page when the LLM path is unavailable.
|
||||
*/
|
||||
export function InteractivePagePublishDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
noteId,
|
||||
content,
|
||||
language,
|
||||
onPublished,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
noteId: string
|
||||
content: string
|
||||
language: string
|
||||
onPublished: (slug: string) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [page, setPage] = useState<PageSpecV1 | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<string | null>(null)
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'generating') {
|
||||
if (!open) setElapsedSec(0)
|
||||
return
|
||||
}
|
||||
const t0 = Date.now()
|
||||
const id = window.setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
|
||||
}, 500)
|
||||
return () => window.clearInterval(id)
|
||||
}, [open, phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhase('idle')
|
||||
setPage(null)
|
||||
setError(null)
|
||||
setProgress(null)
|
||||
return
|
||||
}
|
||||
// Guard: empty content from editor race
|
||||
const wordCount = content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 30) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
t('richTextEditor.publishInteractivePageTooShort') ||
|
||||
'Note trop courte — ajoutez du contenu puis réessayez'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setPhase('generating')
|
||||
setError(null)
|
||||
setPage(null)
|
||||
|
||||
const legacyFallback = async (notice?: string) => {
|
||||
const result = await generateInteractivePage({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
result.reason ||
|
||||
result.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'
|
||||
)
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
if (notice) toast.info(notice)
|
||||
setPage(result.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
// 1. LLM plan (billed)
|
||||
setProgress(
|
||||
t('richTextEditor.publishInteractivePagePlanning') ||
|
||||
'Analyse du contenu — plan de la page…'
|
||||
)
|
||||
const planResult = await generateInteractivePagePlan({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!planResult.ok) {
|
||||
if (planResult.quotaExceeded) {
|
||||
setPhase('error')
|
||||
setError(planResult.error)
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
if (planResult.error === 'unsuitable_content') {
|
||||
setPhase('error')
|
||||
setError(
|
||||
planResult.reason ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Contenu inadapté'
|
||||
)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
// LLM plan unavailable → deterministic full page
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
|
||||
// 2. One LLM call per section, with real progress
|
||||
const plan = planResult.plan
|
||||
const sections: PageSection[] = []
|
||||
let degraded = 0
|
||||
for (let i = 0; i < plan.sections.length; i++) {
|
||||
const planSection = plan.sections[i]
|
||||
const sectionId = `s${i + 1}`
|
||||
setProgress(
|
||||
(
|
||||
t('richTextEditor.publishInteractivePageSectionProgress') ||
|
||||
'Section {current}/{total} : {title}'
|
||||
)
|
||||
.replace('{current}', String(i + 1))
|
||||
.replace('{total}', String(plan.sections.length))
|
||||
.replace('{title}', planSection.title)
|
||||
)
|
||||
const sectionResult = await generateInteractivePageSection({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
pageTitle: plan.heroTitle,
|
||||
sectionId,
|
||||
section: planSection,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (sectionResult.ok) {
|
||||
sections.push(sectionResult.section)
|
||||
} else {
|
||||
degraded += 1
|
||||
sections.push(fallbackSection(sectionId, planSection, language))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assemble + hard validation client-side
|
||||
const candidate = assemblePage(plan, sections, language)
|
||||
const validated = validateInteractivePage(candidate)
|
||||
if (!validated.ok) {
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (degraded > 0) {
|
||||
toast.info(
|
||||
t('richTextEditor.publishInteractivePagePartialFallback') ||
|
||||
'Certaines sections ont été générées en mode simplifié'
|
||||
)
|
||||
}
|
||||
setPage(validated.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Intentionally omit `t` — unstable identity cancels in-flight generation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, content, language, noteId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!page || phase === 'publishing') return
|
||||
setPhase('publishing')
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId,
|
||||
action: 'publish',
|
||||
mode: 'interactive-page',
|
||||
template: 'interactive-page',
|
||||
language,
|
||||
pageSpec: page,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(
|
||||
data.reason ||
|
||||
data.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
t('richTextEditor.publishInteractivePageSuccess') ||
|
||||
'Page interactive publiée !'
|
||||
)
|
||||
onPublished(data.slug)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
toast.error(
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const generatingHint =
|
||||
progress ||
|
||||
(elapsedSec < 15
|
||||
? t('richTextEditor.publishInteractivePageGenerating') ||
|
||||
'Génération de la page…'
|
||||
: elapsedSec < 40
|
||||
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
|
||||
'Construction des sections et démos…'
|
||||
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
|
||||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Clapperboard className="h-4 w-4 text-brand-accent" />
|
||||
{t('richTextEditor.publishInteractivePage') ||
|
||||
'Page interactive'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{phase === 'generating'
|
||||
? generatingHint
|
||||
: t('richTextEditor.publishInteractivePagePreviewHint') ||
|
||||
'Aperçu — vérifiez puis publiez sur l’URL publique'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
{phase === 'generating' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
<p>{generatingHint}</p>
|
||||
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
|
||||
{elapsedSec}s
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'error' ? (
|
||||
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
|
||||
<p className="font-medium">
|
||||
{t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'}
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'preview' || phase === 'publishing' ? (
|
||||
page ? <PageView page={page} demoMode="interactive" /> : null
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={phase === 'publishing'}
|
||||
>
|
||||
{t('general.cancel') || 'Annuler'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={phase !== 'preview' || !page}
|
||||
className="gap-2"
|
||||
>
|
||||
{phase === 'publishing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
{t('richTextEditor.publishInteractivePageConfirm') ||
|
||||
'Publier la page'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user