Files
editor/apps/editor/components/scene-loader.tsx
T

209 lines
6.3 KiB
TypeScript

'use client'
import {
applySceneGraphToEditor,
Editor,
type SceneGraph,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface SceneMeta {
id: string
name: string
projectId: string | null
thumbnailUrl: string | null
version: number
createdAt: string
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
id: 'site',
label: 'Scene',
component: () => null, // Built-in SitePanel handles this
},
]
interface SceneLoaderProps {
initialScene: SceneGraph
meta: SceneMeta
}
type SceneGraphWithCollections = SceneGraph & {
collections?: Record<string, unknown>
}
interface LiveSceneEvent {
eventId: number
sceneId: string
version: number
kind: string
createdAt: string
graph: SceneGraphWithCollections
}
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
})
}
export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const router = useRouter()
const versionRef = useRef(meta.version)
const lastRemoteGraphJsonRef = useRef<string | null>(null)
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const handleLoad = useCallback(async () => initialScene, [initialScene])
const handleSave = useCallback(
async (graph: SceneGraph) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
lastRemoteGraphJsonRef.current = null
suppressRemoteSaveUntilRef.current = 0
return
}
if (isRecentRemoteApply) return
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': String(versionRef.current),
},
body: JSON.stringify({ name: meta.name, graph }),
})
if (response.status === 409) {
setConflict(true)
return
}
if (!response.ok) {
setSaveError(`Save failed (${response.status})`)
return
}
const next = (await response.json()) as SceneMeta
versionRef.current = next.version
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
}
},
[meta.id, meta.name],
)
useEffect(() => {
const source = new EventSource(`/api/scenes/${meta.id}/events`)
source.addEventListener('scene', (event) => {
let payload: LiveSceneEvent
try {
payload = JSON.parse((event as MessageEvent<string>).data) as LiveSceneEvent
} catch {
return
}
if (payload.sceneId !== meta.id) return
if (payload.version <= versionRef.current) return
versionRef.current = payload.version
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
setSaveError(null)
})
source.addEventListener('error', () => {
if (source.readyState === EventSource.CLOSED) {
setSaveError('Live scene connection closed')
}
})
return () => source.close()
}, [meta.id])
const handleThumb = useCallback(
async (_blob: Blob) => {
// TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail.
// Stub endpoint is not yet implemented in v0.1 — skip upload for now.
await fetch(`/api/scenes/${meta.id}/thumbnail`, {
method: 'POST',
// Intentionally no body — endpoint is a stub.
}).catch(() => {
// Swallow errors silently; thumbnail upload is best-effort.
})
},
[meta.id],
)
return (
<div className="relative h-screen w-screen">
{conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
<h2 className="font-semibold text-sm">Another session saved first refresh?</h2>
<p className="mt-1 text-muted-foreground text-xs">
Your changes haven&apos;t been saved. Reload to pick up the latest version.
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80"
onClick={() => router.refresh()}
type="button"
>
Reload
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
onClick={() => setConflict(false)}
type="button"
>
Dismiss
</button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-destructive/50 bg-background p-3 shadow-xl">
<p className="font-medium text-destructive text-xs">{saveError}</p>
</div>
)}
<div className="pointer-events-none absolute top-4 right-4 z-40 flex items-center gap-2">
<Link
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
href="/scenes"
>
All scenes
</Link>
</div>
<Editor
layoutVersion="v2"
onLoad={handleLoad}
onSave={handleSave}
onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
/>
</div>
)
}