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.
411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import dynamic from 'next/dynamic'
|
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { toast } from 'sonner'
|
|
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
|
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
|
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
|
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
|
import '@excalidraw/excalidraw/index.css'
|
|
import type { PresentationSpec } from '@/lib/types/presentation'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
function SlidesLoadingFallback() {
|
|
const { t } = useLanguage()
|
|
return <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">{t('lab.loadingSlides')}</div>
|
|
}
|
|
|
|
const Excalidraw = dynamic(
|
|
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
|
{ ssr: false }
|
|
)
|
|
|
|
const SlidesRenderer = dynamic(
|
|
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
|
|
{ ssr: false, loading: () => <SlidesLoadingFallback /> }
|
|
)
|
|
|
|
interface CanvasBoardProps {
|
|
initialData?: string
|
|
canvasId?: string
|
|
name: string
|
|
}
|
|
|
|
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
|
|
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
|
|
|
|
function parseCanvasScene(initialData?: string): {
|
|
slides: SlidesPayload | null
|
|
pptx: PptxPayload | null
|
|
elements: readonly ExcalidrawElement[]
|
|
files: BinaryFiles
|
|
} {
|
|
if (!initialData) {
|
|
return { slides: null, pptx: null, elements: [], files: {} }
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(initialData)
|
|
if (parsed && parsed.type === 'slides' && parsed.html) {
|
|
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
|
|
}
|
|
if (parsed && parsed.type === 'pptx' && parsed.base64) {
|
|
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
|
|
}
|
|
if (parsed && Array.isArray(parsed)) {
|
|
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
|
|
}
|
|
if (parsed && parsed.elements) {
|
|
const files: BinaryFiles =
|
|
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
|
|
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
|
|
}
|
|
} catch (e) {
|
|
console.error('[CanvasBoard] Failed to parse canvas data:', e)
|
|
}
|
|
return { slides: null, pptx: null, elements: [], files: {} }
|
|
}
|
|
|
|
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|
const { t } = useLanguage()
|
|
const handleDownload = () => {
|
|
try {
|
|
const byteCharacters = atob(data.base64)
|
|
const byteNumbers = new Array(byteCharacters.length)
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers)
|
|
const blob = new Blob([byteArray], {
|
|
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
})
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = data.filename || `${name}.pptx`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
} catch (e) {
|
|
console.error('[PptxViewer] Download failed:', e)
|
|
toast.error(t('lab.downloadFailed'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="absolute inset-0 h-full w-full flex flex-col items-center justify-center bg-white dark:bg-zinc-900">
|
|
<div className="flex flex-col items-center gap-6 p-8">
|
|
<div className="p-4 rounded-2xl bg-primary/10">
|
|
<Presentation className="w-16 h-16 text-primary" />
|
|
</div>
|
|
<div className="text-center">
|
|
<h2 className="text-xl font-semibold text-foreground mb-1">{name}</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
PowerPoint Presentation
|
|
{data.slideCount ? ` • ${data.slideCount} slides` : ''}
|
|
{data.theme ? ` • ${data.theme} theme` : ''}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleDownload}
|
|
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
|
|
>
|
|
<Download className="w-5 h-5" />
|
|
{t('lab.exportPpt')}
|
|
</button>
|
|
<p className="text-xs text-muted-foreground max-w-sm text-center">
|
|
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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)
|
|
// 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(() => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
|
return () => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
|
|
}
|
|
}, [])
|
|
|
|
// Listen for slide-change events from the iframe via postMessage
|
|
useEffect(() => {
|
|
const handler = (e: MessageEvent) => {
|
|
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
|
|
setCurrentSlide(e.data.current)
|
|
}
|
|
}
|
|
window.addEventListener('message', handler)
|
|
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
|
|
try {
|
|
const win = iframe.contentWindow as any
|
|
if (typeof win?.changeSlide === 'function') {
|
|
win.changeSlide(dir)
|
|
return
|
|
}
|
|
} catch (_) {}
|
|
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
|
}
|
|
|
|
const openFullscreen = () => {
|
|
const blob = new Blob([data.html], { type: 'text/html' })
|
|
const url = URL.createObjectURL(blob)
|
|
window.open(url, '_blank')
|
|
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
|
}
|
|
|
|
const downloadHtml = () => {
|
|
const blob = new Blob([data.html], { type: 'text/html' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
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 */}
|
|
<div className="shrink-0 h-11 flex items-center justify-between px-4 bg-zinc-900/80 border-b border-white/10 backdrop-blur-sm z-10">
|
|
<div className="flex items-center gap-2.5 text-white/70 min-w-0">
|
|
<Presentation className="w-4 h-4 shrink-0 text-white/50" />
|
|
<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}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canvasId && (
|
|
<a
|
|
href={`/api/canvas/slides/pptx?id=${canvasId}`}
|
|
download
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
|
title={t('lab.exportPpt')}
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{t('lab.exportPpt')}
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={downloadHtml}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
|
title={t('lab.downloadHtml')}
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{t('lab.downloadHtml')}
|
|
</button>
|
|
<button
|
|
onClick={openFullscreen}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
|
title={t('lab.openFullscreen')}
|
|
>
|
|
<Maximize2 className="w-3.5 h-3.5" />
|
|
{t('lab.openFullscreen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 ? (
|
|
<>
|
|
{!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>
|
|
)}
|
|
<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-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>
|
|
<button
|
|
onClick={() => navigate(1)}
|
|
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>
|
|
)
|
|
}
|
|
|
|
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|
const [isDarkMode, setIsDarkMode] = useState(false)
|
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
|
const [localId, setLocalId] = useState<string | null>(canvasId || null)
|
|
const router = useRouter()
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
|
|
const filesRef = useRef<BinaryFiles>({})
|
|
|
|
const scene = useMemo(
|
|
() => parseCanvasScene(initialData),
|
|
[canvasId, initialData]
|
|
)
|
|
|
|
useEffect(() => {
|
|
filesRef.current = scene.files
|
|
}, [scene])
|
|
|
|
useEffect(() => {
|
|
const checkDarkMode = () => {
|
|
const isDark = document.documentElement.classList.contains('dark')
|
|
setIsDarkMode(isDark)
|
|
}
|
|
|
|
checkDarkMode()
|
|
|
|
const observer = new MutationObserver(checkDarkMode)
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
const handleChange = (
|
|
excalidrawElements: readonly ExcalidrawElement[],
|
|
_appState: AppState,
|
|
files: BinaryFiles
|
|
) => {
|
|
if (files) filesRef.current = files
|
|
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
|
|
setSaveStatus('saving')
|
|
saveTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
|
const res = await fetch('/api/canvas', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: localId || null, name, data: snapshot })
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (data.success && data.canvas?.id) {
|
|
if (!localId) {
|
|
setLocalId(data.canvas.id)
|
|
router.replace(`/lab?id=${data.canvas.id}`, { scroll: false })
|
|
}
|
|
setSaveStatus('saved')
|
|
} else {
|
|
throw new Error(data.error || 'Failed to save')
|
|
}
|
|
} catch (e) {
|
|
console.error('[CanvasBoard] Save failure:', e)
|
|
setSaveStatus('error')
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
if (scene.slides) {
|
|
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
|
|
}
|
|
|
|
if (scene.pptx) {
|
|
return <PptxViewer data={scene.pptx} name={name} />
|
|
}
|
|
|
|
const excalKey = localId ? `excal-${localId}` : 'excal-new'
|
|
|
|
return (
|
|
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
|
<Excalidraw
|
|
key={excalKey}
|
|
excalidrawAPI={(api) => { excalidrawAPIRef.current = api }}
|
|
initialData={{ elements: scene.elements, files: scene.files }}
|
|
theme={isDarkMode ? 'dark' : 'light'}
|
|
onChange={handleChange}
|
|
UIOptions={{
|
|
canvasActions: {
|
|
loadScene: true,
|
|
saveToActiveFile: false,
|
|
clearCanvas: true,
|
|
}
|
|
}}
|
|
validateEmbeddable={false}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|