feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -126,9 +126,12 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||
const { t } = useLanguage()
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [currentSlide, setCurrentSlide] = useState(1)
|
||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
||||
const totalSlides = data.slideCount || 1
|
||||
// Always preview the generated HTML (KaTeX, themes, density).
|
||||
// React SlidesRenderer used an older layout schema and looked broken vs HTML.
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const totalSlides = data.slideCount || data.spec?.slides?.length || 1
|
||||
|
||||
// Hide the global AI floating button while slides are displayed
|
||||
useEffect(() => {
|
||||
@@ -149,10 +152,36 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [])
|
||||
|
||||
// Fit a 16:9 stage into the available panel (letterbox, no crop)
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
const parent = stage.parentElement
|
||||
if (!parent) return
|
||||
|
||||
const fit = () => {
|
||||
const pw = parent.clientWidth
|
||||
const ph = parent.clientHeight
|
||||
if (pw < 32 || ph < 32) return
|
||||
// Design frame 16:9 — pick max size that fits
|
||||
const designW = 1280
|
||||
const designH = 720
|
||||
const scale = Math.min(pw / designW, ph / designH)
|
||||
const w = Math.round(designW * scale)
|
||||
const h = Math.round(designH * scale)
|
||||
stage.style.width = `${w}px`
|
||||
stage.style.height = `${h}px`
|
||||
}
|
||||
|
||||
fit()
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(parent)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const navigate = (dir: number) => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
// Direct contentWindow access (works with allow-same-origin)
|
||||
try {
|
||||
const win = iframe.contentWindow as any
|
||||
if (typeof win?.changeSlide === 'function') {
|
||||
@@ -160,7 +189,6 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: postMessage
|
||||
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
||||
}
|
||||
|
||||
@@ -183,6 +211,9 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||
}
|
||||
|
||||
// Prefer polished HTML; fall back to React only if html is missing
|
||||
const useHtml = Boolean(data.html && data.html.length > 200)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
||||
{/* Toolbar */}
|
||||
@@ -192,7 +223,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{totalSlides > 1 && (
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
||||
{currentSlide} / {totalSlides}
|
||||
{currentSlide} / {totalSlides}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,45 +257,53 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slides: React renderer (spec) or iframe (HTML fallback) */}
|
||||
<div className="flex-1 relative overflow-hidden group">
|
||||
{data.spec ? (
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
) : (
|
||||
|
||||
{/* Stage: 16:9 letterboxed HTML (source of visual truth) */}
|
||||
<div className="flex-1 relative overflow-hidden group flex items-center justify-center bg-zinc-950 p-3 sm:p-4">
|
||||
{useHtml ? (
|
||||
<>
|
||||
{/* Loading overlay — visible until iframe fires onLoad */}
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
{/* Prev button */}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative overflow-hidden rounded-lg shadow-2xl shadow-black/50 border border-white/10 bg-black"
|
||||
style={{ width: '100%', maxWidth: '100%', aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label={t('lab.previousSlide')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label={t('lab.nextSlide')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : data.spec ? (
|
||||
<div className="w-full h-full">
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/40 text-sm">{t('lab.loadingPresentation')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user