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";
|
||||
|
||||
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,6 +552,166 @@ 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>
|
||||
|
||||
<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>
|
||||
<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;
|
||||
|
||||
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
|
||||
@@ -179,7 +763,7 @@ export function AppSidebar() {
|
||||
</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,8 +506,39 @@ export async function getProjectModelPublic(projectId: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Get the model
|
||||
const { data: model } = await supabase
|
||||
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)
|
||||
@@ -516,6 +547,10 @@ export async function getProjectModelPublic(projectId: string): Promise<
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
model = latestModel ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user