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
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:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user