fix(lab): ouvrir le bon diagramme sans F5 (?id alias ?canvas + tri canvases)
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-05 21:52:30 +00:00
parent 7326cfc98f
commit fb6823e25e
4 changed files with 120 additions and 40 deletions

View File

@@ -14,18 +14,18 @@ export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
export default async function LabPage(props: { export default async function LabPage(props: {
searchParams: Promise<{ id?: string }> searchParams: Promise<{ id?: string; canvas?: string }>
}) { }) {
const searchParams = await props.searchParams const searchParams = await props.searchParams
const id = searchParams.id /** Canonical param is id; notifications / older UI used canvas= */
const requestedId = searchParams.id ?? searchParams.canvas
const session = await auth() const session = await auth()
if (!session?.user?.id) redirect('/login') if (!session?.user?.id) redirect('/login')
const canvases = await getCanvases() const canvases = await getCanvases()
// Resolve current canvas correctly const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
// Wrapper for server action creation // Wrapper for server action creation

View File

@@ -34,7 +34,8 @@ export async function getCanvases() {
return prisma.canvas.findMany({ return prisma.canvas.findMany({
where: { userId: session.user.id }, where: { userId: session.user.id },
orderBy: { createdAt: 'asc' } /** Most recently updated first — default canvas matches latest work (agent output, edits) */
orderBy: { updatedAt: 'desc' },
}) })
} }

View File

@@ -331,7 +331,7 @@ export function ContextualAIChat({
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', { toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 10000, id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.', description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?canvas=${poll.canvasId}` } }, action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
}) })
} else { } else {
toast.success(type === 'slides' toast.success(type === 'slides'
@@ -1089,7 +1089,7 @@ export function ContextualAIChat({
{/* Diagram result */} {/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && ( {generateResult?.type === 'diagram' && generateResult.canvasId && (
<a href={`/lab?canvas=${generateResult.canvasId}`} <a href={`/lab?id=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all"> className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<ExternalLink className="h-4 w-4 shrink-0" /> <ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'} {t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}

View File

@@ -1,13 +1,14 @@
'use client' 'use client'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types' import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types' import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css' import '@excalidraw/excalidraw/index.css'
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw, async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false } { ssr: false }
@@ -19,34 +20,106 @@ interface CanvasBoardProps {
name: string name: string
} }
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
function parseCanvasScene(initialData?: string): {
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { 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>
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) { export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false) const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved') const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null) const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const filesRef = useRef<BinaryFiles>({}) const filesRef = useRef<BinaryFiles>({})
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops) const scene = useMemo(
const [elements] = useState<readonly ExcalidrawElement[]>(() => { () => parseCanvasScene(initialData),
if (initialData) { [canvasId, initialData]
try { )
const parsed = JSON.parse(initialData)
if (parsed && Array.isArray(parsed)) { useEffect(() => {
return parsed filesRef.current = scene.files
} else if (parsed && parsed.elements) { }, [scene])
// Restore binary files if present
if (parsed.files && typeof parsed.files === 'object') {
filesRef.current = parsed.files
}
return parsed.elements
}
} catch (e) {
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
}
}
return []
})
// Detect dark mode from html class
useEffect(() => { useEffect(() => {
const checkDarkMode = () => { const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark') const isDark = document.documentElement.classList.contains('dark')
@@ -55,7 +128,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
checkDarkMode() checkDarkMode()
// Observer for theme changes
const observer = new MutationObserver(checkDarkMode) const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
@@ -64,10 +136,9 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const handleChange = ( const handleChange = (
excalidrawElements: readonly ExcalidrawElement[], excalidrawElements: readonly ExcalidrawElement[],
appState: AppState, _appState: AppState,
files: BinaryFiles files: BinaryFiles
) => { ) => {
// Keep files ref up to date so we can include them in the save payload
if (files) filesRef.current = files if (files) filesRef.current = files
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
@@ -75,7 +146,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
setSaveStatus('saving') setSaveStatus('saving')
saveTimeoutRef.current = setTimeout(async () => { saveTimeoutRef.current = setTimeout(async () => {
try { try {
// Save both elements and binary files so images persist across page changes
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current }) const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', { await fetch('/api/canvas', {
method: 'POST', method: 'POST',
@@ -84,26 +154,35 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
}) })
setSaveStatus('saved') setSaveStatus('saved')
} catch (e) { } catch (e) {
console.error("[CanvasBoard] Save failure:", e) console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error') setSaveStatus('error')
} }
}, 2000) }, 2000)
} }
if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} />
}
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
return ( return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr"> <div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
<Excalidraw <Excalidraw
initialData={{ elements, files: filesRef.current }} key={excalKey}
theme={isDarkMode ? "dark" : "light"} excalidrawAPI={(api) => { excalidrawAPIRef.current = api }}
initialData={{ elements: scene.elements, files: scene.files }}
theme={isDarkMode ? 'dark' : 'light'}
onChange={handleChange} onChange={handleChange}
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
UIOptions={{ UIOptions={{
canvasActions: { canvasActions: {
loadScene: false, loadScene: true,
saveToActiveFile: false, saveToActiveFile: false,
clearCanvas: true clearCanvas: true,
} }
}} }}
validateEmbeddable={false}
renderTopRightUI={() => null}
/> />
</div> </div>
) )