Files
Momento/memento-note/components/lab/canvas-board.tsx
Antigravity 9e23c078e9
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Failing after 17s
fix: slide 3 noire, watermark PPTX, quota génération slides
- canvas-board.tsx: préfère data.html (iframe) sur data.spec (ancien renderer)
  → corrige slide 3 noire en mode HTML viewer
- pptx/route.ts: ajoute watermark 'memento-note.com' sur chaque slide
  via buildPptx (PPTX téléchargé depuis le canvas)
- run-for-note/route.ts: checkEntitlementOrThrow avant création agent
- slides.tool.ts: incrementUsageAsync après canvas créé avec succès

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 11:58:31 +00:00

366 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'
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: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
)
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 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('Failed to download presentation')
}
}
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" />
Download .pptx
</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 iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 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)
}, [])
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') {
win.changeSlide(dir)
return
}
} catch (_) {}
// Fallback: postMessage
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)
}
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="Exporter en PowerPoint"
>
<Download className="w-3.5 h-3.5" />
Export PPTX
</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élécharger le HTML"
>
<Download className="w-3.5 h-3.5" />
Export HTML
</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="Ouvrir en plein écran"
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
</button>
</div>
</div>
{/* Slides: iframe (new HTML format) preferred over legacy React renderer */}
<div className="flex-1 relative overflow-hidden group">
{data.html ? (
// New format: standalone animated HTML served via srcDoc
<>
{/* 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">Chargement de la présentation</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 */}
<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"
aria-label="Slide précédent"
>
<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"
aria-label="Slide suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</>
) : data.spec ? (
// Legacy format: old React renderer (recharts)
<SlidesRenderer spec={data.spec} />
) : null}
</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>
)
}