* Add project versioning & publish flow Introduce project model versioning and publishing support. - DB: add published_model_version column to projects (migration + schema + types). - Models: add version status types and helpers, implement getProjectVersionStatus, enhanced getProjectModel to load draft/published/legacy fallbacks, saveProjectModel now manages draft creation/updating and avoids no-op saves, saveProjectVersion to lock/save/publish versions and create next draft, publishProjectModel to republish specific saved versions. Includes scene-graph equality checks and authenticated project ownership checks. - UI: AppSidebar shows publish/draft status, polling refresh, and Save / Save & publish / Publish actions that flush the editor scene before version operations. Hook update: load scene from result.data.model.scene_graph. - Public project loader: prefer published version with legacy fallbacks. This enables safe draft editing, explicit saves, publishing, and keeps autosave working across version locks. * Add project versioning UI, APIs & preview mode Introduce full version management support: UI, backend actions, and editor integration. - UI: Add a Versions popover to the app sidebar with search, relative timestamps, preview, restore and publish controls; disable version actions while previewing and show proper labels/states. Imported new icons, popover and tooltip primitives and added formatRelativeTime helper. - Actions: Add ProjectVersionListItem type and APIs getProjectVersionList and getProjectVersionByNumber to list and fetch saved (non-draft) versions. - Hooks: Extract applySceneGraphToEditor and sync editor selection logic; ensure scene loading applies selection and supports preview mode (which suppresses autosave). - Store: Add isVersionPreviewMode to project store with setter and isSceneLoading flag usage to prevent autosave while previewing. - Misc: Wire up version list loading, previewing, restoring and publishing flows, and refresh version status/list after actions. These changes enable browsing historical project versions, previewing them without triggering autosaves, and restoring or publishing selected versions. * Improve version preview, restore flow & draft handling - Add scene graph snapshot to preserve unsaved work during preview - Include draft versions and metadata in version list - Add getProjectVersionById for direct ID-based lookups - Add autosave status to project store - Refactor sidebar version UI (remove search, use save icon) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
9.9 KiB
TypeScript
309 lines
9.9 KiB
TypeScript
/**
|
|
* Hooks for project model (scene) loading and auto-saving
|
|
*/
|
|
|
|
'use client'
|
|
|
|
import { useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useRef } from 'react'
|
|
import useEditor from '@/store/use-editor'
|
|
import { useProjectStore } from '../projects/store'
|
|
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
|
|
|
|
/** Debounce interval for cloud auto-save (ms). */
|
|
const AUTOSAVE_DEBOUNCE_MS = 1_000
|
|
|
|
function syncEditorSelectionFromCurrentScene() {
|
|
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
|
const sceneRootIds = useScene.getState().rootNodeIds
|
|
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
|
const resolve = (child: any) =>
|
|
typeof child === 'string' ? sceneNodes[child] : child
|
|
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
|
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
|
|
|
if (firstBuilding && firstLevel) {
|
|
useViewer.getState().setSelection({
|
|
buildingId: firstBuilding.id,
|
|
levelId: firstLevel.id,
|
|
selectedIds: [],
|
|
zoneId: null,
|
|
})
|
|
useEditor.getState().setPhase('structure')
|
|
useEditor.getState().setStructureLayer('elements')
|
|
|
|
// Auto-select the wall tool if the level is empty (e.g., brand new project)
|
|
if (!firstLevel.children || firstLevel.children.length === 0) {
|
|
useEditor.getState().setMode('build')
|
|
useEditor.getState().setTool('wall')
|
|
}
|
|
} else {
|
|
useEditor.getState().setPhase('site')
|
|
useViewer.getState().setSelection({
|
|
buildingId: null,
|
|
levelId: null,
|
|
selectedIds: [],
|
|
zoneId: null,
|
|
})
|
|
}
|
|
}
|
|
|
|
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
|
if (sceneGraph?.nodes && sceneGraph.rootNodeIds) {
|
|
const { nodes, rootNodeIds } = sceneGraph
|
|
useScene.getState().setScene(nodes, rootNodeIds)
|
|
} else {
|
|
useScene.getState().clearScene()
|
|
}
|
|
|
|
syncEditorSelectionFromCurrentScene()
|
|
}
|
|
|
|
/**
|
|
* Load the scene when a project becomes active.
|
|
* Saves changes automatically with debouncing.
|
|
*
|
|
* ⚠️ This hook must be mounted in exactly ONE component (the Editor).
|
|
* Mounting it in multiple components causes duplicate save calls.
|
|
*/
|
|
export function useProjectScene() {
|
|
// Subscribe to project store
|
|
const activeProject = useProjectStore((state) => state.activeProject)
|
|
const isLoadingProject = useProjectStore((state) => state.isLoading)
|
|
const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode)
|
|
const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus)
|
|
|
|
const lastProjectIdRef = useRef<string | null>(null)
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
const isSavingRef = useRef(false)
|
|
const currentProjectIdRef = useRef<string | null>(null)
|
|
// Track whether the scene was just loaded from the server so we can skip
|
|
// the first store update (which is the load itself, not a user edit).
|
|
const isLoadingSceneRef = useRef(false)
|
|
// Track whether there are pending changes that arrived while a save was
|
|
// in-flight so we can coalesce them into one follow-up save.
|
|
const pendingSaveRef = useRef(false)
|
|
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
|
|
// Extract project ID for dependency tracking
|
|
const projectId = activeProject?.id ?? null
|
|
|
|
// Load scene when active project changes
|
|
useEffect(() => {
|
|
if (isLoadingProject) {
|
|
return
|
|
}
|
|
|
|
if (!projectId) {
|
|
useProjectStore.getState().setIsVersionPreviewMode(false)
|
|
setAutosaveStatus('idle')
|
|
return
|
|
}
|
|
|
|
// Skip if same project
|
|
if (lastProjectIdRef.current === projectId) {
|
|
return
|
|
}
|
|
|
|
lastProjectIdRef.current = projectId
|
|
|
|
// Load the project's scene
|
|
async function loadScene() {
|
|
// Suppress auto-save for the store update caused by setScene/clearScene
|
|
isLoadingSceneRef.current = true
|
|
useProjectStore.getState().setIsVersionPreviewMode(false)
|
|
setAutosaveStatus('idle')
|
|
|
|
useProjectStore.getState().setIsSceneLoading(true)
|
|
|
|
try {
|
|
const result = await getProjectModel(projectId || '')
|
|
|
|
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
|
|
} catch (error) {
|
|
// Fall back to an empty scene while preserving editor selection sync.
|
|
applySceneGraphToEditor(null)
|
|
} finally {
|
|
useProjectStore.getState().setIsSceneLoading(false)
|
|
}
|
|
|
|
// Allow auto-save again after a tick (let the store update propagate)
|
|
requestAnimationFrame(() => {
|
|
isLoadingSceneRef.current = false
|
|
setAutosaveStatus('saved')
|
|
})
|
|
}
|
|
|
|
loadScene()
|
|
}, [projectId, isLoadingProject, setAutosaveStatus])
|
|
|
|
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
|
const hasDirtyChangesRef = useRef(false)
|
|
|
|
// Auto-save scene changes with debouncing
|
|
useEffect(() => {
|
|
if (!projectId) {
|
|
currentProjectIdRef.current = null
|
|
executeSaveRef.current = null
|
|
setAutosaveStatus('idle')
|
|
return
|
|
}
|
|
|
|
currentProjectIdRef.current = projectId
|
|
|
|
// Use JSON stringification to detect node changes, not just count
|
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
|
|
const unsubscribe = useScene.subscribe((state) => {
|
|
// Skip saves triggered by loading a scene from the server
|
|
if (isLoadingSceneRef.current) {
|
|
// Update the snapshot so the next real edit is compared correctly
|
|
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
return
|
|
}
|
|
|
|
if (useProjectStore.getState().isVersionPreviewMode) {
|
|
// Do not autosave preview scenes. Keep snapshot aligned so returning to
|
|
// latest does not schedule a false-positive save.
|
|
setAutosaveStatus('paused')
|
|
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
return
|
|
}
|
|
|
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
|
|
|
// Only trigger save if nodes actually changed
|
|
if (currentNodesSnapshot === lastNodesSnapshot) {
|
|
return
|
|
}
|
|
|
|
lastNodesSnapshot = currentNodesSnapshot
|
|
hasDirtyChangesRef.current = true
|
|
setAutosaveStatus('pending')
|
|
|
|
// If a save is in-flight, mark pending so we do one follow-up save
|
|
// instead of queuing unlimited concurrent saves.
|
|
if (isSavingRef.current) {
|
|
pendingSaveRef.current = true
|
|
return
|
|
}
|
|
|
|
// Clear existing timeout (debounce reset)
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current)
|
|
}
|
|
|
|
// Debounce save
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSave()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
})
|
|
|
|
async function executeSave() {
|
|
const currentProjectId = currentProjectIdRef.current
|
|
if (!currentProjectId) return
|
|
|
|
if (isLoadingSceneRef.current || useProjectStore.getState().isVersionPreviewMode) {
|
|
// Save is paused while previewing older versions.
|
|
pendingSaveRef.current = true
|
|
setAutosaveStatus('paused')
|
|
return
|
|
}
|
|
|
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
const sceneGraph = { nodes, rootNodeIds }
|
|
|
|
isSavingRef.current = true
|
|
pendingSaveRef.current = false
|
|
setAutosaveStatus('saving')
|
|
|
|
try {
|
|
await saveProjectModel(currentProjectId, sceneGraph)
|
|
hasDirtyChangesRef.current = false
|
|
setAutosaveStatus('saved')
|
|
} finally {
|
|
isSavingRef.current = false
|
|
|
|
// If changes arrived while we were saving, schedule one more save
|
|
if (pendingSaveRef.current) {
|
|
pendingSaveRef.current = false
|
|
setAutosaveStatus('pending')
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSave()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
}
|
|
}
|
|
}
|
|
executeSaveRef.current = executeSave
|
|
|
|
// Flush unsaved changes when the user leaves the page / closes the tab.
|
|
// Uses sendBeacon via keepalive fetch so the request survives page unload.
|
|
function flushOnExit() {
|
|
if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return
|
|
|
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
const sceneGraph = { nodes, rootNodeIds }
|
|
|
|
// Best-effort fire-and-forget save. We use the server action directly
|
|
// (it's just a POST to a Next.js endpoint). If the browser kills it,
|
|
// localStorage still has the data and will sync on next load.
|
|
saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => {
|
|
// Swallow — nothing we can do during unload
|
|
})
|
|
hasDirtyChangesRef.current = false
|
|
}
|
|
|
|
window.addEventListener('beforeunload', flushOnExit)
|
|
|
|
return () => {
|
|
executeSaveRef.current = null
|
|
window.removeEventListener('beforeunload', flushOnExit)
|
|
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current)
|
|
}
|
|
|
|
// Flush on unmount (e.g. navigating away within the SPA)
|
|
flushOnExit()
|
|
|
|
unsubscribe()
|
|
}
|
|
}, [projectId, setAutosaveStatus])
|
|
|
|
useEffect(() => {
|
|
if (!projectId) return
|
|
|
|
if (isVersionPreviewMode) {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current)
|
|
saveTimeoutRef.current = undefined
|
|
}
|
|
if (hasDirtyChangesRef.current) {
|
|
pendingSaveRef.current = true
|
|
}
|
|
setAutosaveStatus('paused')
|
|
return
|
|
}
|
|
|
|
if (isSavingRef.current) {
|
|
return
|
|
}
|
|
|
|
if (hasDirtyChangesRef.current) {
|
|
setAutosaveStatus('pending')
|
|
if (!saveTimeoutRef.current) {
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSaveRef.current?.()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
}
|
|
return
|
|
}
|
|
|
|
setAutosaveStatus('saved')
|
|
}, [isVersionPreviewMode, projectId, setAutosaveStatus])
|
|
}
|