From bee4613b82986f7aca9e5842f925125c20c3b253 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 3 Mar 2026 23:56:46 -0500 Subject: [PATCH] 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 --------- Co-authored-by: Claude Opus 4.6 --- .../components/ui/sidebar/app-sidebar.tsx | 682 +++++++++- .../features/community/lib/models/actions.ts | 965 ++++++++++++-- .../features/community/lib/models/hooks.ts | 203 +-- .../community/lib/projects/actions.ts | 53 +- .../features/community/lib/projects/store.ts | 16 + .../features/community/lib/projects/types.ts | 4 +- packages/db/src/schema/projects/projects.ts | 1 + ...60303213529_marvelous_mikhail_rasputin.sql | 1 + .../meta/20260303213529_snapshot.json | 1141 +++++++++++++++++ supabase/migrations/meta/_journal.json | 7 + 10 files changed, 2822 insertions(+), 251 deletions(-) create mode 100644 supabase/migrations/20260303213529_marvelous_mikhail_rasputin.sql create mode 100644 supabase/migrations/meta/20260303213529_snapshot.json diff --git a/apps/editor/components/ui/sidebar/app-sidebar.tsx b/apps/editor/components/ui/sidebar/app-sidebar.tsx index bde83b38..247a729f 100644 --- a/apps/editor/components/ui/sidebar/app-sidebar.tsx +++ b/apps/editor/components/ui/sidebar/app-sidebar.tsx @@ -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("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(null); + const [versionStatus, setVersionStatus] = useState(null); + const [versionList, setVersionList] = useState([]); + const [isVersionsOpen, setIsVersionsOpen] = useState(false); + const [isVersionListLoading, setIsVersionListLoading] = useState(false); + const [previewVersion, setPreviewVersion] = useState<{ + id: string; + version: number; + } | null>(null); + const [activeVersionAction, setActiveVersionAction] = useState(null); + const [activeVersionItemAction, setActiveVersionItemAction] = useState<{ + version: number; + action: VersionItemAction; + } | null>(null); + const latestSceneSnapshotRef = useRef(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[0], keepPreviewMode: boolean) => { + setIsVersionPreviewMode(true); + applySceneGraphToEditor(sceneGraph); + requestAnimationFrame(() => { + setIsVersionPreviewMode(keepPreviewMode); + }); + }, + [setIsVersionPreviewMode], + ); + + const snapshotCurrentSceneGraph = useCallback((): SceneGraph => { + const { nodes, rootNodeIds } = useScene.getState(); + // Keep a local latest snapshot so preview toggles never drop unsaved work. + return JSON.parse(JSON.stringify({ nodes, rootNodeIds })) as SceneGraph; + }, []); + + const handlePreviewVersion = useCallback( + async (modelId: string, version: number) => { + if (!activeProjectId) return; + + if (!isVersionPreviewMode) { + latestSceneSnapshotRef.current = snapshotCurrentSceneGraph(); + } + + setIsSceneLoading(true); + try { + const result = await getProjectVersionById(activeProjectId, modelId); + if (!result.success || !result.data?.scene_graph) { + return; + } + + applySceneWithoutAutosave(result.data.scene_graph, true); + setPreviewVersion({ id: modelId, version }); + } finally { + setIsSceneLoading(false); + } + }, + [ + activeProjectId, + applySceneWithoutAutosave, + isVersionPreviewMode, + setIsSceneLoading, + snapshotCurrentSceneGraph, + ], + ); + + const handleBackToLatest = useCallback(async () => { + if (!activeProjectId) return; + + setIsSceneLoading(true); + try { + const latestSceneSnapshot = latestSceneSnapshotRef.current; + if (latestSceneSnapshot) { + applySceneWithoutAutosave(latestSceneSnapshot, false); + setPreviewVersion(null); + latestSceneSnapshotRef.current = null; + + setAutosaveStatus("saving"); + const saveResult = await saveProjectModel(activeProjectId, latestSceneSnapshot); + if (saveResult.success) { + if (saveResult.data) { + applyVersionStatus(saveResult.data); + } + setAutosaveStatus("saved"); + await loadVersionList(); + } else { + setAutosaveStatus("pending"); + } + return; + } + + const result = await getProjectModel(activeProjectId); + const sceneGraph = result.success ? result.data?.model?.scene_graph ?? null : null; + applySceneWithoutAutosave(sceneGraph, false); + setPreviewVersion(null); + setAutosaveStatus("saved"); + } finally { + setIsSceneLoading(false); + } + }, [ + activeProjectId, + applySceneWithoutAutosave, + applyVersionStatus, + loadVersionList, + setAutosaveStatus, + setIsSceneLoading, + ]); + + const handleRestoreVersion = useCallback( + async (modelId: string, version: number) => { + if (!activeProjectId || activeVersionItemAction) return; + + setActiveVersionItemAction({ version, action: "restore" }); + setIsSceneLoading(true); + try { + const versionResult = await getProjectVersionById(activeProjectId, modelId); + if (!versionResult.success || !versionResult.data?.scene_graph) { + return; + } + + const saveResult = await saveProjectModel(activeProjectId, versionResult.data.scene_graph, { + restoredFromVersion: version, + }); + if (!saveResult.success) { + console.error("Failed to restore version:", saveResult.error); + return; + } + + if (saveResult.data) { + applyVersionStatus(saveResult.data); + } + + applySceneWithoutAutosave(versionResult.data.scene_graph, false); + setPreviewVersion(null); + latestSceneSnapshotRef.current = null; + setAutosaveStatus("saved"); + await loadVersionList(); + } finally { + setIsSceneLoading(false); + setActiveVersionItemAction(null); + refreshVersionStatus(); + } + }, + [ + activeProjectId, + activeVersionItemAction, + applySceneWithoutAutosave, + applyVersionStatus, + loadVersionList, + refreshVersionStatus, + setAutosaveStatus, + setIsSceneLoading, + ], + ); + + const handlePublishVersion = useCallback( + async (version: number) => { + if (!activeProjectId || activeVersionItemAction) return; + + setActiveVersionItemAction({ version, action: "publish" }); + try { + const result = await publishProjectModel(activeProjectId, { version }); + if (!result.success || !result.data) { + console.error("Failed to publish version:", result.error); + return; + } + + applyVersionStatus(result.data); + await loadVersionList(); + } finally { + setActiveVersionItemAction(null); + refreshVersionStatus(); + } + }, + [ + activeProjectId, + activeVersionItemAction, + applyVersionStatus, + loadVersionList, + refreshVersionStatus, + ], + ); + + const runVersionAction = useCallback( + async (action: VersionAction) => { + if (!activeProjectId || activeVersionAction || isVersionPreviewMode) return; + + setActiveVersionAction(action); + + try { + const { nodes, rootNodeIds } = useScene.getState(); + const sceneGraph = { nodes, rootNodeIds }; + + // Flush latest in-memory scene into the current draft before version actions. + const saveDraftResult = await saveProjectModel(activeProjectId, sceneGraph); + if (!saveDraftResult.success) { + console.error("Failed to save draft:", saveDraftResult.error); + return; + } + if (saveDraftResult.data) { + applyVersionStatus(saveDraftResult.data); + } + + const versionResult = await saveProjectVersion(activeProjectId, { + publish: action !== "save", + }); + if (!versionResult.success || !versionResult.data) { + console.error("Failed to save/publish version:", versionResult.error); + return; + } + + if (useProjectStore.getState().activeProject?.id !== activeProjectId) { + return; + } + + applyVersionStatus(versionResult.data); + await loadVersionList(); + } catch (error) { + console.error("Failed to run version action:", error); + } finally { + setActiveVersionAction(null); + refreshVersionStatus(); + } + }, + [ + activeProjectId, + activeVersionAction, + applyVersionStatus, + isVersionPreviewMode, + loadVersionList, + refreshVersionStatus, + ], + ); + + const isVersionActionRunning = activeVersionAction !== null; + const isVersionActionsDisabled = isVersionActionRunning || isVersionPreviewMode; + const isQuickSaveDisabled = isVersionActionsDisabled; + const quickSaveLabel = activeVersionAction === "save" ? "Saving..." : "Save"; + const quickSaveDescription = isVersionPreviewMode + ? "Back to latest to save" + : "Save a new version"; + + const triggerVersionLabel = useMemo(() => { + if (isVersionPreviewMode && previewVersion !== null) { + return `v${previewVersion.version}`; + } + + if (versionStatus?.draftVersion !== null && versionStatus?.draftVersion !== undefined) { + return "Latest"; + } + + if (versionStatus?.latestSavedVersion !== null && versionStatus?.latestSavedVersion !== undefined) { + return "Latest"; + } + + return "Versions"; + }, [ + isVersionPreviewMode, + previewVersion, + versionStatus?.draftVersion, + versionStatus?.latestSavedVersion, + ]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); @@ -128,58 +552,218 @@ export function AppSidebar() { )} - - {mounted && ( - + + {quickSaveDescription} + - {/* Dark Mode Icon */} -
- + + +
+ +
+ {isVersionListLoading ? ( +
+ Loading versions... +
+ ) : versionList.length === 0 ? ( +
+ No versions found +
+ ) : ( + 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 ( +
+ {isCurrentlyViewed && ( + + )} + + + {!item.isDraft && ( +
+ + + {isPublished ? ( + + Published + + ) : ( + + )} +
+ )} +
+ ); + }) + )} +
+ + + )} + + {mounted && ( +
- - )} + + )} + - {getPanelTitle()} diff --git a/apps/editor/features/community/lib/models/actions.ts b/apps/editor/features/community/lib/models/actions.ts index 2bdea9cb..03b15f40 100644 --- a/apps/editor/features/community/lib/models/actions.ts +++ b/apps/editor/features/community/lib/models/actions.ts @@ -24,81 +24,328 @@ export interface ProjectModel { draft: boolean project_id: string scene_graph: SceneGraph | null + metadata: Record | null created_at: string updated_at: string } +export interface ProjectVersionStatus { + publishedVersion: number | null + draftVersion: number | null + latestSavedVersion: number | null + hasUnsavedDraftChanges: boolean + hasPublishableVersion: boolean +} + +export interface ProjectModelState extends ProjectVersionStatus { + model: ProjectModel | null +} + +export interface ProjectVersionListItem { + id: string + version: number + createdAt: string + updatedAt: string + isPublished: boolean + isDraft: boolean + restoredFromVersion: number | null +} + +type ProjectVersionListRow = { + id: string + version: number + draft: boolean + metadata: unknown + created_at: string + updated_at: string +} + +interface ProjectOwnershipRow { + id: string + owner_id: string + name: string + published_model_version: number | null +} + +type AuthenticatedProjectContext = { + supabase: Awaited> + project: ProjectOwnershipRow +} + +function sceneGraphsEqual( + left: SceneGraph | null | undefined, + right: SceneGraph | null | undefined, +): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null) +} + +function parseModelMetadata(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {} + } + + return { ...(input as Record) } +} + +function readRestoredFromVersion(input: unknown): number | null { + const metadata = parseModelMetadata(input) + const restoredFromVersion = metadata.restoredFromVersion + if (typeof restoredFromVersion !== 'number' || !Number.isFinite(restoredFromVersion)) { + return null + } + + return restoredFromVersion +} + +function buildVersionStatus(params: { + publishedVersion: number | null + draftModel: ProjectModel | null + latestSavedModel: ProjectModel | null +}): ProjectVersionStatus { + const publishedVersion = params.publishedVersion + const draftVersion = params.draftModel?.version ?? null + const latestSavedVersion = params.latestSavedModel?.version ?? null + + const hasUnsavedDraftChanges = params.draftModel + ? params.latestSavedModel + ? !sceneGraphsEqual(params.draftModel.scene_graph, params.latestSavedModel.scene_graph) + : true + : false + + const hasPublishableVersion = + latestSavedVersion !== null && latestSavedVersion !== publishedVersion + + return { + publishedVersion, + draftVersion: draftVersion, + latestSavedVersion, + hasUnsavedDraftChanges, + hasPublishableVersion, + } +} + +async function getProjectVersionModels( + supabase: Awaited>, + projectId: string, +): Promise< + ActionResult<{ + draftModel: ProjectModel | null + latestSavedModel: ProjectModel | null + }> +> { + const { data: draftModel, error: draftModelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('draft', true) + .is('deleted_at', null) + .order('version', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (draftModelError) { + return { + success: false, + error: draftModelError.message, + } + } + + const { data: latestSavedModel, error: latestSavedModelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('draft', false) + .is('deleted_at', null) + .order('version', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (latestSavedModelError) { + return { + success: false, + error: latestSavedModelError.message, + } + } + + return { + success: true, + data: { + draftModel: draftModel ?? null, + latestSavedModel: latestSavedModel ?? null, + }, + } +} + +async function getAuthenticatedProjectContext( + projectId: string, +): Promise> { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id, owner_id, name, published_model_version') + .eq('id', projectId) + .single() + + if (projectError || !project) { + return { + success: false, + error: 'Project not found', + } + } + + if (project.owner_id !== session.user.id) { + return { + success: false, + error: 'Unauthorized', + } + } + + return { + success: true, + data: { + supabase, + project, + }, + } +} + /** - * Get the latest model for a project (highest version) + * Returns publish/draft status for the current project. */ -export async function getProjectModel(projectId: string): Promise> { +export async function getProjectVersionStatus( + projectId: string, +): Promise> { try { - const session = await getSession() - - if (!session?.user) { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { return { success: false, - error: 'Not authenticated', - data: null, + error: contextResult.error, } } - const supabase = await createServerSupabaseClient() - - // Get the project to verify ownership - const { data: project, error: projectError } = await supabase - .from('projects') - .select('id, owner_id') - .eq('id', projectId) - .single<{ id: string; owner_id: string }>() - - if (projectError || !project) { + const { supabase, project } = contextResult.data + const versionModelsResult = await getProjectVersionModels(supabase, projectId) + if (!versionModelsResult.success || !versionModelsResult.data) { return { success: false, - error: 'Project not found', - data: null, + error: versionModelsResult.error, } } - // Verify ownership - if (project.owner_id !== session.user.id) { + const { draftModel, latestSavedModel } = versionModelsResult.data + + return { + success: true, + data: buildVersionStatus({ + publishedVersion: project.published_model_version ?? null, + draftModel, + latestSavedModel, + }), + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch project version status', + } + } +} + +/** + * List all project versions (including current draft), newest first. + */ +export async function getProjectVersionList( + projectId: string, +): Promise> { + try { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { return { success: false, - error: 'Unauthorized', - data: null, + error: contextResult.error, + data: [], } } - // Get the latest model (highest version, then most recent) - const { data: model, error: modelError } = await supabase + const { supabase, project } = contextResult.data + const { data: versions, error: versionsError } = await supabase .from('projects_models') - .select('*') + .select('id, version, draft, metadata, created_at, updated_at') .eq('project_id', projectId) .is('deleted_at', null) .order('version', { ascending: false }) .order('created_at', { ascending: false }) - .limit(1) - .single() + .returns() - console.log('[getProjectModel] Query result:', { - projectId, - hasModel: !!model, - modelError: modelError?.message, - errorCode: modelError?.code, - modelKeys: model ? Object.keys(model) : [], - }) + if (versionsError) { + return { + success: false, + error: versionsError.message, + data: [], + } + } + + const publishedVersion = project.published_model_version ?? null + return { + success: true, + data: (versions ?? []).map((item) => ({ + id: item.id, + version: item.version, + createdAt: item.created_at, + updatedAt: item.updated_at, + isPublished: publishedVersion !== null && item.version === publishedVersion, + isDraft: item.draft, + restoredFromVersion: readRestoredFromVersion(item.metadata), + })), + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch project version list', + data: [], + } + } +} + +/** + * Fetch a single version by version number (saved or draft). + */ +export async function getProjectVersionByNumber( + projectId: string, + version: number, +): Promise> { + try { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { + return { + success: false, + error: contextResult.error, + data: null, + } + } + + const { supabase } = contextResult.data + const { data: model, error: modelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('version', version) + .is('deleted_at', null) + .limit(1) + .maybeSingle() if (modelError) { - // No model found is not an error - just return null - if (modelError.code === 'PGRST116') { - console.log('[getProjectModel] No model found (PGRST116), returning null') - return { - success: true, - data: null, - } - } - - console.log('[getProjectModel] Database error:', modelError) return { success: false, error: modelError.message, @@ -106,66 +353,186 @@ export async function getProjectModel(projectId: string): Promise> { + try { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { + return { + success: false, + error: contextResult.error, + data: null, + } + } + + const { supabase } = contextResult.data + const { data: model, error: modelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('id', modelId) + .is('deleted_at', null) + .limit(1) + .maybeSingle() + + if (modelError) { + return { + success: false, + error: modelError.message, + data: null, + } + } + + return { + success: true, + data: model ?? null, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch project version', + data: null, + } + } +} + +/** + * Get the editor model for a project: + * - Draft if one exists + * - Otherwise the published version + * - Otherwise latest available model (legacy fallback) + */ +export async function getProjectModel(projectId: string): Promise> { + try { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { + return { + success: false, + error: contextResult.error, + } + } + + const { supabase, project } = contextResult.data + const publishedVersion = project.published_model_version ?? null + + const versionModelsResult = await getProjectVersionModels(supabase, projectId) + if (!versionModelsResult.success || !versionModelsResult.data) { + return { + success: false, + error: versionModelsResult.error, + } + } + + const { draftModel, latestSavedModel } = versionModelsResult.data + let modelToLoad = draftModel ?? null + + if (!modelToLoad && publishedVersion !== null) { + const { data: publishedModel, error: publishedModelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('version', publishedVersion) + .eq('draft', false) + .is('deleted_at', null) + .limit(1) + .maybeSingle() + + if (publishedModelError) { + return { + success: false, + error: publishedModelError.message, + } + } + + modelToLoad = publishedModel ?? null + } + + if (!modelToLoad && latestSavedModel) { + modelToLoad = latestSavedModel + } + + if (!modelToLoad) { + const { data: latestModel, error: latestModelError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .is('deleted_at', null) + .order('version', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (latestModelError) { + return { + success: false, + error: latestModelError.message, + } + } + + modelToLoad = latestModel ?? null + } + + const status = buildVersionStatus({ + publishedVersion, + draftModel, + latestSavedModel, + }) + + return { + success: true, + data: { + model: modelToLoad, + ...status, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch project model', + } + } +} + +/** + * Save or update the project's draft model scene graph. + */ +export interface SaveProjectModelOptions { + restoredFromVersion?: number | null +} + export async function saveProjectModel( projectId: string, sceneGraph: SceneGraph, -): Promise> { + options?: SaveProjectModelOptions, +): Promise> { try { - const session = await getSession() - - if (!session?.user) { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { return { success: false, - error: 'Not authenticated', + error: contextResult.error, } } - const supabase = await createServerSupabaseClient() - - // Get the project to verify ownership - const { data: project, error: projectError } = await supabase - .from('projects') - .select('id, owner_id, name') - .eq('id', projectId) - .single<{ id: string; owner_id: string; name: string }>() - - if (projectError || !project) { - return { - success: false, - error: 'Project not found', - } - } - - // Verify ownership - if (project.owner_id !== session.user.id) { - return { - success: false, - error: 'Unauthorized', - } - } + const { supabase, project } = contextResult.data // Determine if scene graph is empty const isEmpty = isSceneGraphEmpty(sceneGraph) @@ -175,27 +542,44 @@ export async function saveProjectModel( .update({ is_empty: isEmpty }) .eq('id', projectId) - // Check if a model already exists - const { data: existingModel } = await supabase - .from('projects_models') - .select('id, version') - .eq('project_id', projectId) - .is('deleted_at', null) - .order('version', { ascending: false }) - .order('created_at', { ascending: false }) - .limit(1) - .single<{ id: string; version: number }>() + const versionModelsResult = await getProjectVersionModels(supabase, projectId) + if (!versionModelsResult.success || !versionModelsResult.data) { + return { + success: false, + error: versionModelsResult.error, + } + } - if (existingModel) { - // Update existing model - const updateData = { + const { draftModel: existingDraftModel, latestSavedModel } = versionModelsResult.data + let savedModel: ProjectModel | null = null + const restoredFromVersionOption = options?.restoredFromVersion + const metadataOverride = + restoredFromVersionOption === undefined + ? undefined + : (() => { + const metadata = parseModelMetadata(existingDraftModel?.metadata ?? null) + + if (typeof restoredFromVersionOption === 'number') { + metadata.restoredFromVersion = restoredFromVersionOption + } else { + delete metadata.restoredFromVersion + } + + return Object.keys(metadata).length > 0 ? metadata : null + })() + + if (existingDraftModel) { + const updateData: Record = { scene_graph: sceneGraph, updated_at: new Date().toISOString(), } + if (metadataOverride !== undefined) { + updateData.metadata = metadataOverride + } const { data: updatedModel, error: updateError } = (await (supabase .from('projects_models') as any) .update(updateData) - .eq('id', existingModel.id) + .eq('id', existingDraftModel.id) .select() .single()) as { data: ProjectModel | null; error: any } @@ -206,22 +590,53 @@ export async function saveProjectModel( } } - return { - success: true, - data: updatedModel as ProjectModel, - message: 'Model updated successfully', - } + savedModel = updatedModel as ProjectModel } else { - // Create new model + const baselineModel = latestSavedModel + + if (baselineModel && sceneGraphsEqual(baselineModel.scene_graph, sceneGraph)) { + return { + success: true, + data: { + model: baselineModel, + ...buildVersionStatus({ + publishedVersion: project.published_model_version ?? null, + draftModel: null, + latestSavedModel: baselineModel, + }), + }, + message: 'No draft changes to save', + } + } + + const { data: latestModel, error: latestModelError } = await supabase + .from('projects_models') + .select('version') + .eq('project_id', projectId) + .is('deleted_at', null) + .order('version', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle<{ version: number }>() + + if (latestModelError) { + return { + success: false, + error: latestModelError.message, + } + } + + const nextVersion = (latestModel?.version ?? 0) + 1 const modelId = createId('model') const insertData = { id: modelId, project_id: projectId, - name: `${project.name} - Editor`, - version: 1, + name: `${project.name} - Draft v${nextVersion}`, + version: nextVersion, draft: true, scene_graph: sceneGraph, + ...(metadataOverride !== undefined ? { metadata: metadataOverride } : {}), } const { data: newModel, error: createError } = (await (supabase .from('projects_models') as any) @@ -236,12 +651,32 @@ export async function saveProjectModel( } } + savedModel = newModel as ProjectModel + } + + if (!savedModel) { return { - success: true, - data: newModel as ProjectModel, - message: 'Model created successfully', + success: false, + error: 'Failed to save project model', } } + + const status = buildVersionStatus({ + publishedVersion: project.published_model_version ?? null, + draftModel: savedModel, + latestSavedModel, + }) + + return { + success: true, + data: { + model: savedModel, + ...status, + }, + message: existingDraftModel + ? 'Draft model updated successfully' + : 'Draft model created successfully', + } } catch (error) { return { success: false, @@ -249,3 +684,317 @@ export async function saveProjectModel( } } } + +async function createNextDraftVersion( + supabase: Awaited>, + params: { + projectId: string + projectName: string + sceneGraph: SceneGraph | null + }, +): Promise> { + const { data: latestModel, error: latestModelError } = await supabase + .from('projects_models') + .select('version') + .eq('project_id', params.projectId) + .is('deleted_at', null) + .order('version', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle<{ version: number }>() + + if (latestModelError) { + return { + success: false, + error: latestModelError.message, + } + } + + const nextVersion = (latestModel?.version ?? 0) + 1 + const modelId = createId('model') + const insertData = { + id: modelId, + project_id: params.projectId, + name: `${params.projectName} - Draft v${nextVersion}`, + version: nextVersion, + draft: true, + scene_graph: params.sceneGraph, + } + + const { data: newDraftModel, error: createError } = (await (supabase + .from('projects_models') as any) + .insert(insertData) + .select() + .single()) as { data: ProjectModel | null; error: any } + + if (createError || !newDraftModel) { + return { + success: false, + error: createError?.message ?? 'Failed to create next draft version', + } + } + + return { + success: true, + data: newDraftModel as ProjectModel, + } +} + +export interface SaveProjectVersionOptions { + publish?: boolean +} + +/** + * Save the current draft into a locked version, and optionally publish it. + * + * Behavior: + * - Save only: lock draft as a saved version, then create the next draft. + * - Save + publish: lock draft, publish it, then create the next draft. + * - Publish when already saved: publish latest saved version directly. + */ +export async function saveProjectVersion( + projectId: string, + options?: SaveProjectVersionOptions, +): Promise> { + try { + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { + return { + success: false, + error: contextResult.error, + } + } + + const { supabase, project } = contextResult.data + const shouldPublish = options?.publish ?? false + let publishedVersion = project.published_model_version ?? null + + const versionModelsResult = await getProjectVersionModels(supabase, projectId) + if (!versionModelsResult.success || !versionModelsResult.data) { + return { + success: false, + error: versionModelsResult.error, + } + } + + let { draftModel, latestSavedModel } = versionModelsResult.data + let didSaveVersion = false + let didPublishVersion = false + + if (draftModel) { + const draftDiffersFromSaved = latestSavedModel + ? !sceneGraphsEqual(draftModel.scene_graph, latestSavedModel.scene_graph) + : true + + if (draftDiffersFromSaved) { + const { data: lockedModel, error: lockDraftError } = (await (supabase + .from('projects_models') as any) + .update({ + draft: false, + updated_at: new Date().toISOString(), + }) + .eq('id', draftModel.id) + .select() + .single()) as { data: ProjectModel | null; error: any } + + if (lockDraftError || !lockedModel) { + return { + success: false, + error: lockDraftError?.message ?? 'Failed to lock draft version', + } + } + + latestSavedModel = lockedModel as ProjectModel + draftModel = null + didSaveVersion = true + } + } + + if (shouldPublish) { + if (!latestSavedModel) { + return { + success: false, + error: 'No saved version available to publish', + } + } + + if (publishedVersion !== latestSavedModel.version) { + const { error: updateProjectError } = await (supabase + .from('projects') as any) + .update({ + published_model_version: latestSavedModel.version, + }) + .eq('id', projectId) + + if (updateProjectError) { + return { + success: false, + error: updateProjectError.message, + } + } + + publishedVersion = latestSavedModel.version + didPublishVersion = true + } + } + + // Keep autosave flowing onto a fresh draft whenever we lock/publish a version. + if ((didSaveVersion || didPublishVersion) && !draftModel && latestSavedModel) { + const nextDraftResult = await createNextDraftVersion(supabase, { + projectId, + projectName: project.name, + sceneGraph: latestSavedModel.scene_graph, + }) + + if (!nextDraftResult.success || !nextDraftResult.data) { + return { + success: false, + error: nextDraftResult.error, + } + } + + draftModel = nextDraftResult.data + } + + const status = buildVersionStatus({ + publishedVersion, + draftModel, + latestSavedModel, + }) + + let message = 'No version changes' + if (didSaveVersion && didPublishVersion && latestSavedModel) { + message = `Saved and published v${latestSavedModel.version}` + } else if (didSaveVersion && latestSavedModel) { + message = `Saved version v${latestSavedModel.version}` + } else if (didPublishVersion && latestSavedModel) { + message = `Published version v${latestSavedModel.version}` + } else if (shouldPublish && latestSavedModel && publishedVersion === latestSavedModel.version) { + message = `Version v${latestSavedModel.version} is already published` + } + + return { + success: true, + data: status, + message, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save project version', + } + } +} + +export interface PublishProjectModelOptions { + version?: number +} + +/** + * Publish a project model version to the community. + * + * - If `options.version` is provided, republish that saved version. + * - Otherwise publish using saveProjectVersion(publish=true) behavior. + */ +export async function publishProjectModel( + projectId: string, + options?: PublishProjectModelOptions, +): Promise> { + try { + if (typeof options?.version !== 'number') { + return await saveProjectVersion(projectId, { publish: true }) + } + + const contextResult = await getAuthenticatedProjectContext(projectId) + if (!contextResult.success || !contextResult.data) { + return { + success: false, + error: contextResult.error, + } + } + + const { supabase, project } = contextResult.data + const { data: targetVersionModel, error: targetVersionError } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('version', options.version) + .eq('draft', false) + .is('deleted_at', null) + .limit(1) + .maybeSingle() + + if (targetVersionError) { + return { + success: false, + error: targetVersionError.message, + } + } + + if (!targetVersionModel) { + return { + success: false, + error: `Version ${options.version} is not a saved version`, + } + } + + const { error: updateProjectError } = await (supabase + .from('projects') as any) + .update({ + published_model_version: targetVersionModel.version, + }) + .eq('id', projectId) + + if (updateProjectError) { + return { + success: false, + error: updateProjectError.message, + } + } + + const versionModelsResult = await getProjectVersionModels(supabase, projectId) + if (!versionModelsResult.success || !versionModelsResult.data) { + return { + success: false, + error: versionModelsResult.error, + } + } + + let { draftModel, latestSavedModel } = versionModelsResult.data + if (!latestSavedModel || latestSavedModel.version < targetVersionModel.version) { + latestSavedModel = targetVersionModel + } + + if (!draftModel) { + const nextDraftResult = await createNextDraftVersion(supabase, { + projectId, + projectName: project.name, + sceneGraph: targetVersionModel.scene_graph, + }) + + if (!nextDraftResult.success || !nextDraftResult.data) { + return { + success: false, + error: nextDraftResult.error, + } + } + + draftModel = nextDraftResult.data + } + + return { + success: true, + data: buildVersionStatus({ + publishedVersion: targetVersionModel.version, + draftModel, + latestSavedModel, + }), + message: `Published version v${targetVersionModel.version}`, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to publish project model', + } + } +} diff --git a/apps/editor/features/community/lib/models/hooks.ts b/apps/editor/features/community/lib/models/hooks.ts index 1c51abad..d2fbccf2 100644 --- a/apps/editor/features/community/lib/models/hooks.ts +++ b/apps/editor/features/community/lib/models/hooks.ts @@ -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 + 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(null) const saveTimeoutRef = useRef(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) | 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 - 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 - 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]) } diff --git a/apps/editor/features/community/lib/projects/actions.ts b/apps/editor/features/community/lib/projects/actions.ts index ac0dd3bf..830cef3a 100644 --- a/apps/editor/features/community/lib/projects/actions.ts +++ b/apps/editor/features/community/lib/projects/actions.ts @@ -506,15 +506,50 @@ export async function getProjectModelPublic(projectId: string): Promise< } } - // Get the model - const { data: model } = await supabase - .from('projects_models') - .select('*') - .eq('project_id', projectId) - .is('deleted_at', null) - .order('version', { ascending: false }) - .limit(1) - .maybeSingle() + const publishedVersion = projectData.published_model_version as number | null + let model: any | null = null + + if (publishedVersion !== null) { + const { data: publishedModel } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('version', publishedVersion) + .eq('draft', false) + .is('deleted_at', null) + .limit(1) + .maybeSingle() + + model = publishedModel ?? null + } + + // Legacy fallback for projects created before version publishing was added. + if (!model) { + const { data: latestPublishedModel } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .eq('draft', false) + .is('deleted_at', null) + .order('version', { ascending: false }) + .limit(1) + .maybeSingle() + + if (latestPublishedModel) { + model = latestPublishedModel + } else { + const { data: latestModel } = await supabase + .from('projects_models') + .select('*') + .eq('project_id', projectId) + .is('deleted_at', null) + .order('version', { ascending: false }) + .limit(1) + .maybeSingle() + + model = latestModel ?? null + } + } return { success: true, diff --git a/apps/editor/features/community/lib/projects/store.ts b/apps/editor/features/community/lib/projects/store.ts index 9b878a46..3478c7cf 100644 --- a/apps/editor/features/community/lib/projects/store.ts +++ b/apps/editor/features/community/lib/projects/store.ts @@ -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 setActiveProject: (projectId: string) => Promise setIsSceneLoading: (loading: boolean) => void + setIsVersionPreviewMode: (preview: boolean) => void + setAutosaveStatus: (status: ProjectStore['autosaveStatus']) => void initialize: () => Promise updateActiveThumbnail: (thumbnailUrl: string) => void } export const useProjectStore = create((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((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) => ({ diff --git a/apps/editor/features/community/lib/projects/types.ts b/apps/editor/features/community/lib/projects/types.ts index b8b5f40e..274a009e 100644 --- a/apps/editor/features/community/lib/projects/types.ts +++ b/apps/editor/features/community/lib/projects/types.ts @@ -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 & { show_scans_public?: boolean; show_guides_public?: boolean } + Insert: Omit & { show_scans_public?: boolean; show_guides_public?: boolean; published_model_version?: number | null } Update: Partial> } 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 diff --git a/packages/db/src/schema/projects/projects.ts b/packages/db/src/schema/projects/projects.ts index 3aa5c4d3..969b50cc 100644 --- a/packages/db/src/schema/projects/projects.ts +++ b/packages/db/src/schema/projects/projects.ts @@ -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), diff --git a/supabase/migrations/20260303213529_marvelous_mikhail_rasputin.sql b/supabase/migrations/20260303213529_marvelous_mikhail_rasputin.sql new file mode 100644 index 00000000..957549ea --- /dev/null +++ b/supabase/migrations/20260303213529_marvelous_mikhail_rasputin.sql @@ -0,0 +1 @@ +ALTER TABLE "projects" ADD COLUMN "published_model_version" integer; \ No newline at end of file diff --git a/supabase/migrations/meta/20260303213529_snapshot.json b/supabase/migrations/meta/20260303213529_snapshot.json new file mode 100644 index 00000000..9ddc2701 --- /dev/null +++ b/supabase/migrations/meta/20260303213529_snapshot.json @@ -0,0 +1,1141 @@ +{ + "id": "022423df-5615-44b1-a725-a9c1f4baa074", + "prevId": "b7bcfd85-59f4-4979-aa8c-69dcb20c4b32", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_project_id": { + "name": "active_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_sessions_impersonated_by_auth_users_id_fk": { + "name": "auth_sessions_impersonated_by_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_url": { + "name": "x_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube_url": { + "name": "youtube_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "role": { + "name": "role", + "type": "auth_user_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unique_index": { + "name": "email_unique_index", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "username_unique_index": { + "name": "username_unique_index", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_index": { + "name": "verification_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_addresses": { + "name": "projects_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "street_number": { + "name": "street_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_short": { + "name": "route_short", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_long": { + "name": "state_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_suffix": { + "name": "postal_code_suffix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_long": { + "name": "country_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "address_components_unique": { + "name": "address_components_unique", + "nullsNotDistinct": false, + "columns": [ + "street_number", + "route", + "city", + "state", + "postal_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.project_assets": { + "name": "project_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_assets_project_id_projects_id_fk": { + "name": "project_assets_project_id_projects_id_fk", + "tableFrom": "project_assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_likes": { + "name": "projects_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_likes_project_id_projects_id_fk": { + "name": "projects_likes_project_id_projects_id_fk", + "tableFrom": "projects_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_likes_project_user_unique": { + "name": "projects_likes_project_user_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_models": { + "name": "projects_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_models_project_id_projects_id_fk": { + "name": "projects_models_project_id_projects_id_fk", + "tableFrom": "projects_models", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_model_version": { + "name": "published_model_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_empty": { + "name": "is_empty", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_scans_public": { + "name": "show_scans_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_guides_public": { + "name": "show_guides_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_address_idx": { + "name": "project_address_idx", + "columns": [ + { + "expression": "address_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_idx": { + "name": "project_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_private_idx": { + "name": "project_is_private_idx", + "columns": [ + { + "expression": "is_private", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_likes_idx": { + "name": "project_likes_idx", + "columns": [ + { + "expression": "likes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_address_id_projects_addresses_id_fk": { + "name": "projects_address_id_projects_addresses_id_fk", + "tableFrom": "projects", + "tableTo": "projects_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_owner_id_auth_users_id_fk": { + "name": "projects_owner_id_auth_users_id_fk", + "tableFrom": "projects", + "tableTo": "auth_users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.auth_user_roles": { + "name": "auth_user_roles", + "schema": "public", + "values": [ + "user", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 4b2e3583..8c7bb9c5 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file