'use client' import '../lib/bootstrap' import { applySceneGraphToEditor, Editor, type SceneGraph, type SidebarTab, } from '@pascal-app/editor' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' 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 } 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(null) const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(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).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 (
{conflict && (

Another session saved first — refresh?

Your changes haven't been saved. Reload to pick up the latest version.

)} {saveError && !conflict && (

{saveError}

)}
All scenes
} viewerToolbarRight={} />
) }