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:
co-authored by
Claude Opus 4.6
parent
0edbc65e8d
commit
bee4613b82
@@ -1,25 +1,88 @@
|
|||||||
"use client";
|
"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 { 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 { motion } from "framer-motion";
|
||||||
|
import { useScene } from "@pascal-app/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
} from "@/components/ui/primitives/sidebar";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import { SettingsPanel } from "./panels/settings-panel";
|
import { SettingsPanel } from "./panels/settings-panel";
|
||||||
import { SitePanel } from "./panels/site-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 { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
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() {
|
export function AppSidebar() {
|
||||||
|
type VersionAction = "save" | "savePublish" | "publish";
|
||||||
|
type VersionItemAction = "restore" | "publish";
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||||
const activeProject = useProjectStore((s) => s.activeProject);
|
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 theme = useViewer((state) => state.theme);
|
||||||
const setTheme = useViewer((state) => state.setTheme);
|
const setTheme = useViewer((state) => state.setTheme);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -27,6 +90,21 @@ export function AppSidebar() {
|
|||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [titleValue, setTitleValue] = useState("");
|
const [titleValue, setTitleValue] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -62,6 +140,352 @@ export function AppSidebar() {
|
|||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
}, [titleValue, activeProject]);
|
}, [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) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -128,58 +552,218 @@ export function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={cn("shrink-0 flex items-center gap-1 transition-all duration-200", isEditingTitle && "hidden")}>
|
||||||
{mounted && (
|
{activeProjectId && (
|
||||||
<button
|
<Popover open={isVersionsOpen} onOpenChange={setIsVersionsOpen}>
|
||||||
className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
|
<div className="inline-flex h-8 overflow-hidden rounded-full border border-border/50 bg-black/20">
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
aria-label="Toggle theme"
|
<button
|
||||||
>
|
type="button"
|
||||||
<div className="relative flex">
|
onClick={() => runVersionAction("save")}
|
||||||
{/* Sliding Background */}
|
disabled={isQuickSaveDisabled}
|
||||||
<motion.div
|
className={cn(
|
||||||
className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
|
"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",
|
||||||
initial={false}
|
isQuickSaveDisabled
|
||||||
animate={{
|
? "cursor-not-allowed opacity-50"
|
||||||
x: theme === "light" ? "100%" : "0%",
|
: "hover:bg-black/30",
|
||||||
}}
|
)}
|
||||||
transition={{
|
style={{
|
||||||
type: "spring",
|
width: "clamp(48px, calc(var(--sidebar-width) - 16.5rem), 80px)",
|
||||||
stiffness: 500,
|
}}
|
||||||
damping: 35,
|
>
|
||||||
}}
|
<span className="pointer-events-none inline-flex min-w-0 items-center gap-1 transition-opacity group-hover/save-trigger:opacity-0">
|
||||||
style={{ width: "50%" }}
|
<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>
|
||||||
|
|
||||||
{/* Dark Mode Icon */}
|
<PopoverTrigger asChild>
|
||||||
<div
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
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"
|
||||||
theme === "dark"
|
>
|
||||||
? "text-foreground"
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
: "text-muted-foreground"
|
</button>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
>
|
|
||||||
<Moon className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-[min(320px,calc(var(--sidebar-width)-3rem),calc(100vw-2rem))] min-w-[230px] p-2"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
|
||||||
{/* Light Mode Icon */}
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
key={item.id}
|
||||||
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
className={cn(
|
||||||
theme === "light"
|
"group/version-item relative mb-0.5 flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors",
|
||||||
? "text-foreground"
|
isCurrentlyViewed ? "bg-accent/25" : "hover:bg-accent/20"
|
||||||
: "text-muted-foreground"
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{isCurrentlyViewed && (
|
||||||
<Sun className="h-3.5 w-3.5" />
|
<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>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{getPanelTitle()}
|
{getPanelTitle()}
|
||||||
</span>
|
</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 { useEffect, useRef } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { useProjectStore } from '../projects/store'
|
import { useProjectStore } from '../projects/store'
|
||||||
import { getProjectModel, saveProjectModel } from './actions'
|
import { getProjectModel, saveProjectModel, type SceneGraph } from './actions'
|
||||||
|
|
||||||
/** Debounce interval for cloud auto-save (ms). */
|
/** 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.
|
* Load the scene when a project becomes active.
|
||||||
@@ -25,6 +71,8 @@ export function useProjectScene() {
|
|||||||
// Subscribe to project store
|
// Subscribe to project store
|
||||||
const activeProject = useProjectStore((state) => state.activeProject)
|
const activeProject = useProjectStore((state) => state.activeProject)
|
||||||
const isLoadingProject = useProjectStore((state) => state.isLoading)
|
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 lastProjectIdRef = useRef<string | null>(null)
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
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
|
// Track whether there are pending changes that arrived while a save was
|
||||||
// in-flight so we can coalesce them into one follow-up save.
|
// in-flight so we can coalesce them into one follow-up save.
|
||||||
const pendingSaveRef = useRef(false)
|
const pendingSaveRef = useRef(false)
|
||||||
|
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
||||||
|
|
||||||
// Extract project ID for dependency tracking
|
// Extract project ID for dependency tracking
|
||||||
const projectId = activeProject?.id ?? null
|
const projectId = activeProject?.id ?? null
|
||||||
@@ -47,6 +96,8 @@ export function useProjectScene() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
|
useProjectStore.getState().setIsVersionPreviewMode(false)
|
||||||
|
setAutosaveStatus('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,93 +112,18 @@ export function useProjectScene() {
|
|||||||
async function loadScene() {
|
async function loadScene() {
|
||||||
// Suppress auto-save for the store update caused by setScene/clearScene
|
// Suppress auto-save for the store update caused by setScene/clearScene
|
||||||
isLoadingSceneRef.current = true
|
isLoadingSceneRef.current = true
|
||||||
|
useProjectStore.getState().setIsVersionPreviewMode(false)
|
||||||
|
setAutosaveStatus('idle')
|
||||||
|
|
||||||
useProjectStore.getState().setIsSceneLoading(true)
|
useProjectStore.getState().setIsSceneLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
useScene.getState().clearScene()
|
|
||||||
|
|
||||||
const result = await getProjectModel(projectId || '')
|
const result = await getProjectModel(projectId || '')
|
||||||
|
|
||||||
if (result.success && result.data?.scene_graph) {
|
applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null)
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fall back to clear scene
|
// Fall back to an empty scene while preserving editor selection sync.
|
||||||
useScene.getState().clearScene()
|
applySceneGraphToEditor(null)
|
||||||
|
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
useProjectStore.getState().setIsSceneLoading(false)
|
useProjectStore.getState().setIsSceneLoading(false)
|
||||||
}
|
}
|
||||||
@@ -155,11 +131,12 @@ export function useProjectScene() {
|
|||||||
// Allow auto-save again after a tick (let the store update propagate)
|
// Allow auto-save again after a tick (let the store update propagate)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
isLoadingSceneRef.current = false
|
isLoadingSceneRef.current = false
|
||||||
|
setAutosaveStatus('saved')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
loadScene()
|
loadScene()
|
||||||
}, [projectId, isLoadingProject])
|
}, [projectId, isLoadingProject, setAutosaveStatus])
|
||||||
|
|
||||||
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
||||||
const hasDirtyChangesRef = useRef(false)
|
const hasDirtyChangesRef = useRef(false)
|
||||||
@@ -168,6 +145,8 @@ export function useProjectScene() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
currentProjectIdRef.current = null
|
currentProjectIdRef.current = null
|
||||||
|
executeSaveRef.current = null
|
||||||
|
setAutosaveStatus('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +163,14 @@ export function useProjectScene() {
|
|||||||
return
|
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)
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
||||||
|
|
||||||
// Only trigger save if nodes actually changed
|
// Only trigger save if nodes actually changed
|
||||||
@@ -193,6 +180,7 @@ export function useProjectScene() {
|
|||||||
|
|
||||||
lastNodesSnapshot = currentNodesSnapshot
|
lastNodesSnapshot = currentNodesSnapshot
|
||||||
hasDirtyChangesRef.current = true
|
hasDirtyChangesRef.current = true
|
||||||
|
setAutosaveStatus('pending')
|
||||||
|
|
||||||
// If a save is in-flight, mark pending so we do one follow-up save
|
// If a save is in-flight, mark pending so we do one follow-up save
|
||||||
// instead of queuing unlimited concurrent saves.
|
// instead of queuing unlimited concurrent saves.
|
||||||
@@ -208,6 +196,7 @@ export function useProjectScene() {
|
|||||||
|
|
||||||
// Debounce save
|
// Debounce save
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveTimeoutRef.current = undefined
|
||||||
executeSave()
|
executeSave()
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
}, AUTOSAVE_DEBOUNCE_MS)
|
||||||
})
|
})
|
||||||
@@ -216,27 +205,39 @@ export function useProjectScene() {
|
|||||||
const currentProjectId = currentProjectIdRef.current
|
const currentProjectId = currentProjectIdRef.current
|
||||||
if (!currentProjectId) return
|
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 { nodes, rootNodeIds } = useScene.getState()
|
||||||
const sceneGraph = { nodes, rootNodeIds }
|
const sceneGraph = { nodes, rootNodeIds }
|
||||||
|
|
||||||
isSavingRef.current = true
|
isSavingRef.current = true
|
||||||
pendingSaveRef.current = false
|
pendingSaveRef.current = false
|
||||||
|
setAutosaveStatus('saving')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveProjectModel(currentProjectId, sceneGraph)
|
await saveProjectModel(currentProjectId, sceneGraph)
|
||||||
hasDirtyChangesRef.current = false
|
hasDirtyChangesRef.current = false
|
||||||
|
setAutosaveStatus('saved')
|
||||||
} finally {
|
} finally {
|
||||||
isSavingRef.current = false
|
isSavingRef.current = false
|
||||||
|
|
||||||
// If changes arrived while we were saving, schedule one more save
|
// If changes arrived while we were saving, schedule one more save
|
||||||
if (pendingSaveRef.current) {
|
if (pendingSaveRef.current) {
|
||||||
pendingSaveRef.current = false
|
pendingSaveRef.current = false
|
||||||
|
setAutosaveStatus('pending')
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveTimeoutRef.current = undefined
|
||||||
executeSave()
|
executeSave()
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
}, AUTOSAVE_DEBOUNCE_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
executeSaveRef.current = executeSave
|
||||||
|
|
||||||
// Flush unsaved changes when the user leaves the page / closes the tab.
|
// Flush unsaved changes when the user leaves the page / closes the tab.
|
||||||
// Uses sendBeacon via keepalive fetch so the request survives page unload.
|
// Uses sendBeacon via keepalive fetch so the request survives page unload.
|
||||||
@@ -258,6 +259,7 @@ export function useProjectScene() {
|
|||||||
window.addEventListener('beforeunload', flushOnExit)
|
window.addEventListener('beforeunload', flushOnExit)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
executeSaveRef.current = null
|
||||||
window.removeEventListener('beforeunload', flushOnExit)
|
window.removeEventListener('beforeunload', flushOnExit)
|
||||||
|
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
@@ -269,5 +271,38 @@ export function useProjectScene() {
|
|||||||
|
|
||||||
unsubscribe()
|
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 publishedVersion = projectData.published_model_version as number | null
|
||||||
const { data: model } = await supabase
|
let model: any | null = null
|
||||||
.from('projects_models')
|
|
||||||
.select('*')
|
if (publishedVersion !== null) {
|
||||||
.eq('project_id', projectId)
|
const { data: publishedModel } = await supabase
|
||||||
.is('deleted_at', null)
|
.from('projects_models')
|
||||||
.order('version', { ascending: false })
|
.select('*')
|
||||||
.limit(1)
|
.eq('project_id', projectId)
|
||||||
.maybeSingle()
|
.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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import {
|
|||||||
} from './actions'
|
} from './actions'
|
||||||
|
|
||||||
interface ProjectStore {
|
interface ProjectStore {
|
||||||
|
// Autosave lifecycle for the latest draft scene
|
||||||
|
autosaveStatus: 'idle' | 'pending' | 'saving' | 'saved' | 'paused'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
activeProject: Project | null
|
activeProject: Project | null
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isSceneLoading: boolean
|
isSceneLoading: boolean
|
||||||
|
isVersionPreviewMode: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -23,16 +27,20 @@ interface ProjectStore {
|
|||||||
fetchActiveProject: () => Promise<void>
|
fetchActiveProject: () => Promise<void>
|
||||||
setActiveProject: (projectId: string) => Promise<void>
|
setActiveProject: (projectId: string) => Promise<void>
|
||||||
setIsSceneLoading: (loading: boolean) => void
|
setIsSceneLoading: (loading: boolean) => void
|
||||||
|
setIsVersionPreviewMode: (preview: boolean) => void
|
||||||
|
setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void
|
||||||
initialize: () => Promise<void>
|
initialize: () => Promise<void>
|
||||||
updateActiveThumbnail: (thumbnailUrl: string) => void
|
updateActiveThumbnail: (thumbnailUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
|
autosaveStatus: 'idle',
|
||||||
activeProject: null,
|
activeProject: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isSceneLoading: false,
|
isSceneLoading: false,
|
||||||
|
isVersionPreviewMode: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
// Fetch all projects
|
// Fetch all projects
|
||||||
@@ -86,6 +94,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||||||
set({ isSceneLoading: loading })
|
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)
|
// Patch the active project's thumbnail URL in place (no refetch)
|
||||||
updateActiveThumbnail: (thumbnailUrl: string) => {
|
updateActiveThumbnail: (thumbnailUrl: string) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type DbProject = {
|
|||||||
views: number
|
views: number
|
||||||
likes: number
|
likes: number
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
|
published_model_version: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DbProjectAddress = {
|
export type DbProjectAddress = {
|
||||||
@@ -58,7 +59,7 @@ export type Database = {
|
|||||||
Tables: {
|
Tables: {
|
||||||
projects: {
|
projects: {
|
||||||
Row: DbProject
|
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'>>
|
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
|
||||||
}
|
}
|
||||||
projects_addresses: {
|
projects_addresses: {
|
||||||
@@ -113,6 +114,7 @@ export type Project = {
|
|||||||
views: number
|
views: number
|
||||||
likes: number
|
likes: number
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
|
published_model_version: number | null
|
||||||
address: {
|
address: {
|
||||||
id: string
|
id: string
|
||||||
street_number?: string
|
street_number?: string
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const projects = pgTable(
|
|||||||
.references(() => users.id, { onDelete: 'set null' }),
|
.references(() => users.id, { onDelete: 'set null' }),
|
||||||
detailsJson: t.jsonb('details_json'),
|
detailsJson: t.jsonb('details_json'),
|
||||||
metadata: t.jsonb('metadata'),
|
metadata: t.jsonb('metadata'),
|
||||||
|
publishedModelVersion: t.integer('published_model_version'),
|
||||||
// Community features
|
// Community features
|
||||||
isPrivate: t.boolean('is_private').notNull().default(true),
|
isPrivate: t.boolean('is_private').notNull().default(true),
|
||||||
isEmpty: t.boolean('is_empty').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
@@ -64,6 +64,13 @@
|
|||||||
"when": 1772225008787,
|
"when": 1772225008787,
|
||||||
"tag": "20260227204328_backfill_empty_projects",
|
"tag": "20260227204328_backfill_empty_projects",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772573729680,
|
||||||
|
"tag": "20260303213529_marvelous_mikhail_rasputin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user