Add project versioning & publish flow (#130)

* 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>
This commit is contained in:
Aymeric Rabot
2026-03-03 23:56:46 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0edbc65e8d
commit bee4613b82
10 changed files with 2822 additions and 251 deletions
+633 -49
View File
@@ -1,25 +1,88 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { IconRail, type PanelId } from "./icon-rail";
import { Pencil, Moon, Sun, Monitor } from "lucide-react";
import {
ArrowUpCircle,
ChevronDown,
Clock3,
Moon,
Pencil,
RotateCcw,
Save,
Sun,
} from "lucide-react";
import { motion } from "framer-motion";
import { useScene } from "@pascal-app/core";
import {
Sidebar,
SidebarContent,
SidebarHeader,
} from "@/components/ui/primitives/sidebar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/primitives/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils";
import { SettingsPanel } from "./panels/settings-panel";
import { SitePanel } from "./panels/site-panel";
import {
getProjectModel,
getProjectVersionById,
getProjectVersionList,
getProjectVersionStatus,
publishProjectModel,
saveProjectModel,
saveProjectVersion,
type SceneGraph,
type ProjectVersionListItem,
type ProjectVersionStatus,
} from "@/features/community/lib/models/actions";
import { useProjectStore } from "@/features/community/lib/projects/store";
import { updateProjectName } from "@/features/community/lib/projects/actions";
import { useViewer } from "@pascal-app/viewer";
import { applySceneGraphToEditor } from "@/features/community/lib/models/hooks";
function formatRelativeTime(value: string): string {
const target = new Date(value).getTime();
const now = Date.now();
const diffSeconds = Math.max(1, Math.floor((now - target) / 1000));
if (diffSeconds < 60) return `${diffSeconds}s ago`;
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return `${diffMinutes}min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths}mo ago`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears}y ago`;
}
export function AppSidebar() {
type VersionAction = "save" | "savePublish" | "publish";
type VersionItemAction = "restore" | "publish";
const [activePanel, setActivePanel] = useState<PanelId>("site");
const activeProject = useProjectStore((s) => s.activeProject);
const isVersionPreviewMode = useProjectStore((s) => s.isVersionPreviewMode);
const setIsVersionPreviewMode = useProjectStore((s) => s.setIsVersionPreviewMode);
const setIsSceneLoading = useProjectStore((s) => s.setIsSceneLoading);
const setAutosaveStatus = useProjectStore((s) => s.setAutosaveStatus);
const theme = useViewer((state) => state.theme);
const setTheme = useViewer((state) => state.setTheme);
const [mounted, setMounted] = useState(false);
@@ -27,6 +90,21 @@ export function AppSidebar() {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [versionStatus, setVersionStatus] = useState<ProjectVersionStatus | null>(null);
const [versionList, setVersionList] = useState<ProjectVersionListItem[]>([]);
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
const [isVersionListLoading, setIsVersionListLoading] = useState(false);
const [previewVersion, setPreviewVersion] = useState<{
id: string;
version: number;
} | null>(null);
const [activeVersionAction, setActiveVersionAction] = useState<VersionAction | null>(null);
const [activeVersionItemAction, setActiveVersionItemAction] = useState<{
version: number;
action: VersionItemAction;
} | null>(null);
const latestSceneSnapshotRef = useRef<SceneGraph | null>(null);
const activeProjectId = activeProject?.id ?? null;
useEffect(() => {
setMounted(true);
@@ -62,6 +140,352 @@ export function AppSidebar() {
setIsEditingTitle(false);
}, [titleValue, activeProject]);
const applyVersionStatus = useCallback(
(status: ProjectVersionStatus) => {
if (!activeProjectId) return;
const publishedVersion = status.publishedVersion ?? null;
setVersionStatus(status);
useProjectStore.setState((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
published_model_version: publishedVersion,
}
: null,
projects: state.projects.map((project) =>
project.id === activeProjectId
? {
...project,
published_model_version: publishedVersion,
}
: project,
),
}));
},
[activeProjectId],
);
const refreshVersionStatus = useCallback(async () => {
if (!activeProjectId) {
setVersionStatus(null);
return;
}
const statusResult = await getProjectVersionStatus(activeProjectId);
if (!statusResult.success || !statusResult.data) {
return;
}
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
return;
}
applyVersionStatus(statusResult.data);
}, [activeProjectId, applyVersionStatus]);
useEffect(() => {
if (!activeProjectId) {
setVersionStatus(null);
return;
}
refreshVersionStatus();
const intervalId = window.setInterval(() => {
refreshVersionStatus();
}, 12_000);
return () => {
window.clearInterval(intervalId);
};
}, [activeProjectId, refreshVersionStatus]);
const loadVersionList = useCallback(async () => {
if (!activeProjectId) {
setVersionList([]);
return;
}
setIsVersionListLoading(true);
try {
const result = await getProjectVersionList(activeProjectId);
if (!result.success || !result.data) {
setVersionList([]);
return;
}
setVersionList(result.data);
} finally {
setIsVersionListLoading(false);
}
}, [activeProjectId]);
useEffect(() => {
if (!activeProjectId) {
setVersionList([]);
setPreviewVersion(null);
setIsVersionPreviewMode(false);
latestSceneSnapshotRef.current = null;
return;
}
loadVersionList();
setPreviewVersion(null);
setIsVersionPreviewMode(false);
latestSceneSnapshotRef.current = null;
}, [activeProjectId, loadVersionList, setIsVersionPreviewMode]);
useEffect(() => {
if (isVersionsOpen) {
loadVersionList();
}
}, [isVersionsOpen, loadVersionList]);
const applySceneWithoutAutosave = useCallback(
(sceneGraph: Parameters<typeof applySceneGraphToEditor>[0], keepPreviewMode: boolean) => {
setIsVersionPreviewMode(true);
applySceneGraphToEditor(sceneGraph);
requestAnimationFrame(() => {
setIsVersionPreviewMode(keepPreviewMode);
});
},
[setIsVersionPreviewMode],
);
const snapshotCurrentSceneGraph = useCallback((): SceneGraph => {
const { nodes, rootNodeIds } = useScene.getState();
// Keep a local latest snapshot so preview toggles never drop unsaved work.
return JSON.parse(JSON.stringify({ nodes, rootNodeIds })) as SceneGraph;
}, []);
const handlePreviewVersion = useCallback(
async (modelId: string, version: number) => {
if (!activeProjectId) return;
if (!isVersionPreviewMode) {
latestSceneSnapshotRef.current = snapshotCurrentSceneGraph();
}
setIsSceneLoading(true);
try {
const result = await getProjectVersionById(activeProjectId, modelId);
if (!result.success || !result.data?.scene_graph) {
return;
}
applySceneWithoutAutosave(result.data.scene_graph, true);
setPreviewVersion({ id: modelId, version });
} finally {
setIsSceneLoading(false);
}
},
[
activeProjectId,
applySceneWithoutAutosave,
isVersionPreviewMode,
setIsSceneLoading,
snapshotCurrentSceneGraph,
],
);
const handleBackToLatest = useCallback(async () => {
if (!activeProjectId) return;
setIsSceneLoading(true);
try {
const latestSceneSnapshot = latestSceneSnapshotRef.current;
if (latestSceneSnapshot) {
applySceneWithoutAutosave(latestSceneSnapshot, false);
setPreviewVersion(null);
latestSceneSnapshotRef.current = null;
setAutosaveStatus("saving");
const saveResult = await saveProjectModel(activeProjectId, latestSceneSnapshot);
if (saveResult.success) {
if (saveResult.data) {
applyVersionStatus(saveResult.data);
}
setAutosaveStatus("saved");
await loadVersionList();
} else {
setAutosaveStatus("pending");
}
return;
}
const result = await getProjectModel(activeProjectId);
const sceneGraph = result.success ? result.data?.model?.scene_graph ?? null : null;
applySceneWithoutAutosave(sceneGraph, false);
setPreviewVersion(null);
setAutosaveStatus("saved");
} finally {
setIsSceneLoading(false);
}
}, [
activeProjectId,
applySceneWithoutAutosave,
applyVersionStatus,
loadVersionList,
setAutosaveStatus,
setIsSceneLoading,
]);
const handleRestoreVersion = useCallback(
async (modelId: string, version: number) => {
if (!activeProjectId || activeVersionItemAction) return;
setActiveVersionItemAction({ version, action: "restore" });
setIsSceneLoading(true);
try {
const versionResult = await getProjectVersionById(activeProjectId, modelId);
if (!versionResult.success || !versionResult.data?.scene_graph) {
return;
}
const saveResult = await saveProjectModel(activeProjectId, versionResult.data.scene_graph, {
restoredFromVersion: version,
});
if (!saveResult.success) {
console.error("Failed to restore version:", saveResult.error);
return;
}
if (saveResult.data) {
applyVersionStatus(saveResult.data);
}
applySceneWithoutAutosave(versionResult.data.scene_graph, false);
setPreviewVersion(null);
latestSceneSnapshotRef.current = null;
setAutosaveStatus("saved");
await loadVersionList();
} finally {
setIsSceneLoading(false);
setActiveVersionItemAction(null);
refreshVersionStatus();
}
},
[
activeProjectId,
activeVersionItemAction,
applySceneWithoutAutosave,
applyVersionStatus,
loadVersionList,
refreshVersionStatus,
setAutosaveStatus,
setIsSceneLoading,
],
);
const handlePublishVersion = useCallback(
async (version: number) => {
if (!activeProjectId || activeVersionItemAction) return;
setActiveVersionItemAction({ version, action: "publish" });
try {
const result = await publishProjectModel(activeProjectId, { version });
if (!result.success || !result.data) {
console.error("Failed to publish version:", result.error);
return;
}
applyVersionStatus(result.data);
await loadVersionList();
} finally {
setActiveVersionItemAction(null);
refreshVersionStatus();
}
},
[
activeProjectId,
activeVersionItemAction,
applyVersionStatus,
loadVersionList,
refreshVersionStatus,
],
);
const runVersionAction = useCallback(
async (action: VersionAction) => {
if (!activeProjectId || activeVersionAction || isVersionPreviewMode) return;
setActiveVersionAction(action);
try {
const { nodes, rootNodeIds } = useScene.getState();
const sceneGraph = { nodes, rootNodeIds };
// Flush latest in-memory scene into the current draft before version actions.
const saveDraftResult = await saveProjectModel(activeProjectId, sceneGraph);
if (!saveDraftResult.success) {
console.error("Failed to save draft:", saveDraftResult.error);
return;
}
if (saveDraftResult.data) {
applyVersionStatus(saveDraftResult.data);
}
const versionResult = await saveProjectVersion(activeProjectId, {
publish: action !== "save",
});
if (!versionResult.success || !versionResult.data) {
console.error("Failed to save/publish version:", versionResult.error);
return;
}
if (useProjectStore.getState().activeProject?.id !== activeProjectId) {
return;
}
applyVersionStatus(versionResult.data);
await loadVersionList();
} catch (error) {
console.error("Failed to run version action:", error);
} finally {
setActiveVersionAction(null);
refreshVersionStatus();
}
},
[
activeProjectId,
activeVersionAction,
applyVersionStatus,
isVersionPreviewMode,
loadVersionList,
refreshVersionStatus,
],
);
const isVersionActionRunning = activeVersionAction !== null;
const isVersionActionsDisabled = isVersionActionRunning || isVersionPreviewMode;
const isQuickSaveDisabled = isVersionActionsDisabled;
const quickSaveLabel = activeVersionAction === "save" ? "Saving..." : "Save";
const quickSaveDescription = isVersionPreviewMode
? "Back to latest to save"
: "Save a new version";
const triggerVersionLabel = useMemo(() => {
if (isVersionPreviewMode && previewVersion !== null) {
return `v${previewVersion.version}`;
}
if (versionStatus?.draftVersion !== null && versionStatus?.draftVersion !== undefined) {
return "Latest";
}
if (versionStatus?.latestSavedVersion !== null && versionStatus?.latestSavedVersion !== undefined) {
return "Latest";
}
return "Versions";
}, [
isVersionPreviewMode,
previewVersion,
versionStatus?.draftVersion,
versionStatus?.latestSavedVersion,
]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -128,58 +552,218 @@ export function AppSidebar() {
</div>
)}
</div>
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
{activeProjectId && (
<Popover open={isVersionsOpen} onOpenChange={setIsVersionsOpen}>
<div className="inline-flex h-8 overflow-hidden rounded-full border border-border/50 bg-black/20">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => runVersionAction("save")}
disabled={isQuickSaveDisabled}
className={cn(
"group/save-trigger relative inline-flex h-full min-w-0 items-center border-r border-border/50 px-1.5 text-[10px] transition-colors",
isQuickSaveDisabled
? "cursor-not-allowed opacity-50"
: "hover:bg-black/30",
)}
style={{
width: "clamp(48px, calc(var(--sidebar-width) - 16.5rem), 80px)",
}}
>
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
<Clock3 className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-left text-muted-foreground">
{triggerVersionLabel}
</span>
</span>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center gap-1 opacity-0 transition-opacity group-hover/save-trigger:opacity-100">
<Save className="h-3 w-3 shrink-0 text-foreground" />
<span className="font-medium text-foreground">{quickSaveLabel}</span>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">{quickSaveDescription}</TooltipContent>
</Tooltip>
{mounted && (
<button
className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
aria-label="Toggle theme"
>
<div className="relative flex">
{/* Sliding Background */}
<motion.div
className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
initial={false}
animate={{
x: theme === "light" ? "100%" : "0%",
}}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
style={{ width: "50%" }}
/>
{/* Dark Mode Icon */}
<div
className={cn(
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "dark"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Moon className="h-3.5 w-3.5" />
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex h-full w-6 items-center justify-center text-muted-foreground transition-colors hover:bg-black/30 hover:text-foreground data-[state=open]:bg-black/35"
>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</PopoverTrigger>
</div>
{/* Light Mode Icon */}
<div
className={cn(
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "light"
? "text-foreground"
: "text-muted-foreground"
)}
<PopoverContent
align="end"
className="w-[min(320px,calc(var(--sidebar-width)-3rem),calc(100vw-2rem))] min-w-[230px] p-2"
sideOffset={8}
>
<Sun className="h-3.5 w-3.5" />
<div className="max-h-[280px] overflow-y-auto">
{isVersionListLoading ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
Loading versions...
</div>
) : versionList.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
No versions found
</div>
) : (
versionList.map((item) => {
const isPublished = item.isPublished;
const isCurrentlyViewed = isVersionPreviewMode
? previewVersion?.id === item.id
: item.isDraft;
const isActionPending = activeVersionItemAction?.version === item.version;
return (
<div
key={item.id}
className={cn(
"group/version-item relative mb-0.5 flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors",
isCurrentlyViewed ? "bg-accent/25" : "hover:bg-accent/20"
)}
>
{isCurrentlyViewed && (
<span className="pointer-events-none absolute right-0 top-1 bottom-1 w-0.5 rounded-full bg-primary/70" />
)}
<button
type="button"
onClick={() =>
item.isDraft
? handleBackToLatest()
: handlePreviewVersion(item.id, item.version)
}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium leading-none">
{item.isDraft
? "Latest"
: `Version ${item.version}`}
</span>
{item.isDraft && item.restoredFromVersion !== null && (
<span className="text-[10px] text-muted-foreground">
restored from v{item.restoredFromVersion}
</span>
)}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{formatRelativeTime(item.updatedAt)}
</div>
</button>
{!item.isDraft && (
<div className="absolute right-1 top-1 flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRestoreVersion(item.id, item.version);
}}
disabled={!!activeVersionItemAction}
className={cn(
"group/restore pointer-events-none inline-flex h-6 items-center rounded-md border border-border/50 bg-background/80 px-1.5 text-muted-foreground opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-border hover:bg-accent/20 hover:text-foreground",
isActionPending &&
activeVersionItemAction?.action === "restore" &&
"border-primary/40 text-primary"
)}
>
<RotateCcw className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/restore:ml-1 group-hover/restore:max-w-14 group-hover/restore:opacity-100">
Restore
</span>
</button>
{isPublished ? (
<span className="inline-flex h-6 items-center rounded-md bg-emerald-500/15 px-2 text-[10px] font-medium text-emerald-400">
Published
</span>
) : (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handlePublishVersion(item.version);
}}
disabled={!!activeVersionItemAction}
className={cn(
"group/publish pointer-events-none inline-flex h-6 items-center rounded-md border border-sky-500/35 bg-sky-500/10 px-1.5 text-sky-300 opacity-0 transition-all duration-150 group-hover/version-item:pointer-events-auto group-hover/version-item:opacity-100 hover:border-sky-400/50 hover:bg-sky-500/20 hover:text-sky-200",
isActionPending &&
activeVersionItemAction?.action === "publish" &&
"border-sky-300/60 text-sky-200"
)}
>
<ArrowUpCircle className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all duration-150 group-hover/publish:ml-1 group-hover/publish:max-w-14 group-hover/publish:opacity-100">
Publish
</span>
</button>
)}
</div>
)}
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
)}
{mounted && (
<button
className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
aria-label="Toggle theme"
>
<div className="relative flex">
{/* Sliding Background */}
<motion.div
className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
initial={false}
animate={{
x: theme === "light" ? "100%" : "0%",
}}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
style={{ width: "50%" }}
/>
{/* Dark Mode Icon */}
<div
className={cn(
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "dark"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Moon className="h-3.5 w-3.5" />
</div>
{/* Light Mode Icon */}
<div
className={cn(
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "light"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Sun className="h-3.5 w-3.5" />
</div>
</div>
</div>
</button>
)}
</button>
)}
</div>
</div>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{getPanelTitle()}
</span>
File diff suppressed because it is too large Load Diff
@@ -9,10 +9,56 @@ 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 } from './actions'
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
/** Debounce interval for cloud auto-save (ms). */
const AUTOSAVE_DEBOUNCE_MS = 10_000
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.
@@ -25,6 +71,8 @@ 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)
@@ -36,6 +84,7 @@ export function useProjectScene() {
// 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
@@ -47,6 +96,8 @@ export function useProjectScene() {
}
if (!projectId) {
useProjectStore.getState().setIsVersionPreviewMode(false)
setAutosaveStatus('idle')
return
}
@@ -61,93 +112,18 @@ export function useProjectScene() {
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 {
useScene.getState().clearScene()
const result = await getProjectModel(projectId || '')
if (result.success && result.data?.scene_graph) {
// Load the scene graph into the store
const { nodes, rootNodeIds } = result.data.scene_graph
useScene.getState().setScene(nodes, rootNodeIds)
} else {
// No scene found - clear the scene
useScene.getState().clearScene()
}
// Auto-select the first building + level after store is updated
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,
})
}
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
} catch (error) {
// Fall back to clear scene
useScene.getState().clearScene()
// Auto-select the first building + level from the cleared scene
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,
})
}
// Fall back to an empty scene while preserving editor selection sync.
applySceneGraphToEditor(null)
} finally {
useProjectStore.getState().setIsSceneLoading(false)
}
@@ -155,11 +131,12 @@ export function useProjectScene() {
// Allow auto-save again after a tick (let the store update propagate)
requestAnimationFrame(() => {
isLoadingSceneRef.current = false
setAutosaveStatus('saved')
})
}
loadScene()
}, [projectId, isLoadingProject])
}, [projectId, isLoadingProject, setAutosaveStatus])
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
const hasDirtyChangesRef = useRef(false)
@@ -168,6 +145,8 @@ export function useProjectScene() {
useEffect(() => {
if (!projectId) {
currentProjectIdRef.current = null
executeSaveRef.current = null
setAutosaveStatus('idle')
return
}
@@ -184,6 +163,14 @@ export function useProjectScene() {
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
@@ -193,6 +180,7 @@ export function useProjectScene() {
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.
@@ -208,6 +196,7 @@ export function useProjectScene() {
// Debounce save
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
executeSave()
}, AUTOSAVE_DEBOUNCE_MS)
})
@@ -216,27 +205,39 @@ export function useProjectScene() {
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.
@@ -258,6 +259,7 @@ export function useProjectScene() {
window.addEventListener('beforeunload', flushOnExit)
return () => {
executeSaveRef.current = null
window.removeEventListener('beforeunload', flushOnExit)
if (saveTimeoutRef.current) {
@@ -269,5 +271,38 @@ export function useProjectScene() {
unsubscribe()
}
}, [projectId])
}, [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])
}
@@ -506,15 +506,50 @@ export async function getProjectModelPublic(projectId: string): Promise<
}
}
// Get the model
const { data: model } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.limit(1)
.maybeSingle()
const publishedVersion = projectData.published_model_version as number | null
let model: any | null = null
if (publishedVersion !== null) {
const { data: publishedModel } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('version', publishedVersion)
.eq('draft', false)
.is('deleted_at', null)
.limit(1)
.maybeSingle()
model = publishedModel ?? null
}
// Legacy fallback for projects created before version publishing was added.
if (!model) {
const { data: latestPublishedModel } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.eq('draft', false)
.is('deleted_at', null)
.order('version', { ascending: false })
.limit(1)
.maybeSingle()
if (latestPublishedModel) {
model = latestPublishedModel
} else {
const { data: latestModel } = await supabase
.from('projects_models')
.select('*')
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.limit(1)
.maybeSingle()
model = latestModel ?? null
}
}
return {
success: true,
@@ -11,11 +11,15 @@ import {
} from './actions'
interface ProjectStore {
// Autosave lifecycle for the latest draft scene
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused'
// State
activeProject: Project | null
projects: Project[]
isLoading: boolean
isSceneLoading: boolean
isVersionPreviewMode: boolean
error: string | null
// Actions
@@ -23,16 +27,20 @@ interface ProjectStore {
fetchActiveProject: () => Promise<void>
setActiveProject: (projectId: string) => Promise<void>
setIsSceneLoading: (loading: boolean) => void
setIsVersionPreviewMode: (preview: boolean) => void
setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void
initialize: () => Promise<void>
updateActiveThumbnail: (thumbnailUrl: string) => void
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
// Initial state
autosaveStatus: 'idle',
activeProject: null,
projects: [],
isLoading: true,
isSceneLoading: false,
isVersionPreviewMode: false,
error: null,
// Fetch all projects
@@ -86,6 +94,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
set({ isSceneLoading: loading })
},
setIsVersionPreviewMode: (preview: boolean) => {
set({ isVersionPreviewMode: preview })
},
setAutosaveStatus: (status) => {
set({ autosaveStatus: status })
},
// Patch the active project's thumbnail URL in place (no refetch)
updateActiveThumbnail: (thumbnailUrl: string) => {
set((state) => ({
@@ -19,6 +19,7 @@ export type DbProject = {
views: number
likes: number
thumbnail_url: string | null
published_model_version: number | null
}
export type DbProjectAddress = {
@@ -58,7 +59,7 @@ export type Database = {
Tables: {
projects: {
Row: DbProject
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public'> & { show_scans_public?: boolean; show_guides_public?: boolean }
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public' | 'published_model_version'> & { show_scans_public?: boolean; show_guides_public?: boolean; published_model_version?: number | null }
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
}
projects_addresses: {
@@ -113,6 +114,7 @@ export type Project = {
views: number
likes: number
thumbnail_url: string | null
published_model_version: number | null
address: {
id: string
street_number?: string
@@ -18,6 +18,7 @@ export const projects = pgTable(
.references(() => users.id, { onDelete: 'set null' }),
detailsJson: t.jsonb('details_json'),
metadata: t.jsonb('metadata'),
publishedModelVersion: t.integer('published_model_version'),
// Community features
isPrivate: t.boolean('is_private').notNull().default(true),
isEmpty: t.boolean('is_empty').notNull().default(true),
@@ -0,0 +1 @@
ALTER TABLE "projects" ADD COLUMN "published_model_version" integer;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1772225008787,
"tag": "20260227204328_backfill_empty_projects",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1772573729680,
"tag": "20260303213529_marvelous_mikhail_rasputin",
"breakpoints": true
}
]
}