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 default async function LabPage(props: {
searchParams: Promise<{ id?: string }>
searchParams: Promise<{ id?: string; canvas?: string }>
}) {
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()
if (!session?.user?.id) redirect('/login')
const canvases = await getCanvases()
// Resolve current canvas correctly
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
// Wrapper for server action creation

View File

@@ -34,7 +34,8 @@ export async function getCanvases() {
return prisma.canvas.findMany({
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é !', {
id: toastId, duration: 10000,
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 {
toast.success(type === 'slides'
@@ -1089,7 +1089,7 @@ export function ContextualAIChat({
{/* Diagram result */}
{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">
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}

View File

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