'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('idle') const [page, setPage] = useState(null) const [error, setError] = useState(null) const [progress, setProgress] = useState(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 ( {t('richTextEditor.publishInteractivePage') || 'Page interactive'} {phase === 'generating' ? generatingHint : t('richTextEditor.publishInteractivePagePreviewHint') || 'Aperçu — vérifiez puis publiez sur l’URL publique'}
{phase === 'generating' ? (

{generatingHint}

{elapsedSec}s

) : null} {phase === 'error' ? (

{t('richTextEditor.publishInteractivePageFailed') || 'Échec de la page interactive'}

{error}

) : null} {phase === 'preview' || phase === 'publishing' ? ( page ? : null ) : null}
) }