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