auth flow + property switching
This commit is contained in:
@@ -4,6 +4,7 @@ import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-a
|
|||||||
import { Viewer } from '@pascal-app/viewer'
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { usePropertyScene } from '@/features/cloud-sync/lib/models/hooks'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
@@ -16,8 +17,8 @@ import { ExportManager } from './export-manager'
|
|||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
|
|
||||||
|
// Load default scene initially (will be replaced when property loads)
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
console.log('Loaded scene in editor')
|
|
||||||
initSpatialGridSync()
|
initSpatialGridSync()
|
||||||
initSpaceDetectionSync(useScene, useEditor)
|
initSpaceDetectionSync(useScene, useEditor)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ The cloud sync feature provides:
|
|||||||
|
|
||||||
- **Authentication** - Sign in with magic link via Better Auth
|
- **Authentication** - Sign in with magic link via Better Auth
|
||||||
- **Property Management** - Create and manage properties with Google Maps address search
|
- **Property Management** - Create and manage properties with Google Maps address search
|
||||||
|
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
|
||||||
|
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
|
||||||
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -51,6 +53,13 @@ features/cloud-sync/
|
|||||||
3. Properties are associated with the authenticated user
|
3. Properties are associated with the authenticated user
|
||||||
4. User can switch between properties
|
4. User can switch between properties
|
||||||
|
|
||||||
|
### Scene Management
|
||||||
|
1. When a property is selected, its scene is loaded from `properties_models` table
|
||||||
|
2. If no scene exists, loads default empty scene
|
||||||
|
3. Scene changes are auto-saved every 2 seconds (debounced)
|
||||||
|
4. Updates existing model (highest version) instead of creating new ones
|
||||||
|
5. Scene graph includes all nodes and hierarchy
|
||||||
|
|
||||||
### Database Integration
|
### Database Integration
|
||||||
- Uses Supabase (PostgreSQL) for database access
|
- Uses Supabase (PostgreSQL) for database access
|
||||||
- Server actions use service role key to bypass RLS
|
- Server actions use service role key to bypass RLS
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Cloud } from 'lucide-react'
|
import { Cloud } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
import { useAuth } from '../lib/auth/hooks'
|
||||||
|
import { usePropertyStore } from '../lib/properties/store'
|
||||||
import { ProfileDropdown } from './profile-dropdown'
|
import { ProfileDropdown } from './profile-dropdown'
|
||||||
import { PropertyDropdown } from './property-dropdown'
|
import { PropertyDropdown } from './property-dropdown'
|
||||||
import { SignInDialog } from './sign-in-dialog'
|
import { SignInDialog } from './sign-in-dialog'
|
||||||
@@ -16,6 +17,14 @@ import { SignInDialog } from './sign-in-dialog'
|
|||||||
export function CloudSaveButton() {
|
export function CloudSaveButton() {
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
||||||
|
const initialize = usePropertyStore(state => state.initialize)
|
||||||
|
|
||||||
|
// Initialize property store when authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, initialize])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { GoogleAddressSearch } from './google-address-search'
|
|||||||
interface NewPropertyDialogProps {
|
interface NewPropertyDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSuccess?: () => void
|
onSuccess?: (propertyId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddressData {
|
interface AddressData {
|
||||||
@@ -60,10 +60,10 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
|
|||||||
country: address.country || 'US',
|
country: address.country || 'US',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success && result.data) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setAddress(null)
|
setAddress(null)
|
||||||
onSuccess?.()
|
onSuccess?.(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to create property')
|
setError(result.error || 'Failed to create property')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useActiveProperty, useProperties } from '../lib/properties/hooks'
|
import { usePropertyStore } from '../lib/properties/store'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,13 +11,21 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/primitives/dropdown-menu'
|
} from '@/components/ui/primitives/dropdown-menu'
|
||||||
import { NewPropertyDialog } from './new-property-dialog'
|
import { NewPropertyDialog } from './new-property-dialog'
|
||||||
|
import { usePropertyScene } from '../lib/models/hooks'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PropertyDropdown - Shows active property and allows switching between properties
|
* PropertyDropdown - Shows active property and allows switching between properties
|
||||||
*/
|
*/
|
||||||
export function PropertyDropdown() {
|
export function PropertyDropdown() {
|
||||||
const { properties, isLoading: propertiesLoading, refetch } = useProperties()
|
usePropertyScene() // Load and auto-save property scenes
|
||||||
const { activeProperty, setActiveProperty, isPending } = useActiveProperty()
|
|
||||||
|
// Use property store
|
||||||
|
const properties = usePropertyStore(state => state.properties)
|
||||||
|
const activeProperty = usePropertyStore(state => state.activeProperty)
|
||||||
|
const isLoading = usePropertyStore(state => state.isLoading)
|
||||||
|
const setActiveProperty = usePropertyStore(state => state.setActiveProperty)
|
||||||
|
const fetchProperties = usePropertyStore(state => state.fetchProperties)
|
||||||
|
|
||||||
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
|
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
|
||||||
|
|
||||||
const handlePropertySelect = async (propertyId: string) => {
|
const handlePropertySelect = async (propertyId: string) => {
|
||||||
@@ -28,8 +36,9 @@ export function PropertyDropdown() {
|
|||||||
setIsNewPropertyDialogOpen(true)
|
setIsNewPropertyDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePropertyCreated = () => {
|
const handlePropertyCreated = async (propertyId: string) => {
|
||||||
refetch()
|
// Set the newly created property as active (this will also fetch properties)
|
||||||
|
await setActiveProperty(propertyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +47,7 @@ export function PropertyDropdown() {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
||||||
disabled={propertiesLoading || isPending}
|
disabled={isLoading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Property model actions - Server actions for scene loading/saving
|
||||||
|
* Manages 3D models (scene graphs) stored in properties_models table
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||||
|
import { createServerSupabaseClient } from '../database/server'
|
||||||
|
import { getSession } from '../auth/server'
|
||||||
|
import { createId } from '../utils/id-generator'
|
||||||
|
import type { ActionResult } from '../properties/actions'
|
||||||
|
|
||||||
|
export interface SceneGraph {
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
|
rootNodeIds: AnyNodeId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertyModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: number
|
||||||
|
draft: boolean
|
||||||
|
property_id: string
|
||||||
|
scene_graph: SceneGraph | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest model for a property (highest version)
|
||||||
|
*/
|
||||||
|
export async function getPropertyModel(propertyId: string): Promise<ActionResult<PropertyModel | null>> {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Not authenticated',
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
|
||||||
|
// Get the property to verify ownership
|
||||||
|
const { data: property, error: propertyError } = await supabase
|
||||||
|
.from('properties')
|
||||||
|
.select('id, owner_id')
|
||||||
|
.eq('id', propertyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (propertyError || !property) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Property not found',
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (property.owner_id !== session.user.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest model (highest version, then most recent)
|
||||||
|
const { data: model, error: modelError } = await supabase
|
||||||
|
.from('properties_models')
|
||||||
|
.select('*')
|
||||||
|
.eq('property_id', propertyId)
|
||||||
|
.is('deleted_at', null)
|
||||||
|
.order('version', { ascending: false })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
console.log('[getPropertyModel] Query result:', {
|
||||||
|
propertyId,
|
||||||
|
hasModel: !!model,
|
||||||
|
modelError: modelError?.message,
|
||||||
|
errorCode: modelError?.code,
|
||||||
|
modelKeys: model ? Object.keys(model) : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (modelError) {
|
||||||
|
// No model found is not an error - just return null
|
||||||
|
if (modelError.code === 'PGRST116') {
|
||||||
|
console.log('[getPropertyModel] No model found (PGRST116), returning null')
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[getPropertyModel] Database error:', modelError)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: modelError.message,
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[getPropertyModel] Model found:', {
|
||||||
|
id: model.id,
|
||||||
|
version: model.version,
|
||||||
|
hasSceneGraph: !!model.scene_graph,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: model as PropertyModel,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch property model',
|
||||||
|
data: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update a property model's scene graph
|
||||||
|
* If a model exists, updates it. Otherwise creates a new one.
|
||||||
|
*/
|
||||||
|
export async function savePropertyModel(
|
||||||
|
propertyId: string,
|
||||||
|
sceneGraph: SceneGraph,
|
||||||
|
): Promise<ActionResult<PropertyModel>> {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Not authenticated',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
|
||||||
|
// Get the property to verify ownership
|
||||||
|
const { data: property, error: propertyError } = await supabase
|
||||||
|
.from('properties')
|
||||||
|
.select('id, owner_id, name')
|
||||||
|
.eq('id', propertyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (propertyError || !property) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Property not found',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (property.owner_id !== session.user.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a model already exists
|
||||||
|
const { data: existingModel } = await supabase
|
||||||
|
.from('properties_models')
|
||||||
|
.select('id, version')
|
||||||
|
.eq('property_id', propertyId)
|
||||||
|
.is('deleted_at', null)
|
||||||
|
.order('version', { ascending: false })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (existingModel) {
|
||||||
|
// Update existing model
|
||||||
|
const { data: updatedModel, error: updateError } = await supabase
|
||||||
|
.from('properties_models')
|
||||||
|
.update({
|
||||||
|
scene_graph: sceneGraph as any,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', existingModel.id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: updateError.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updatedModel as PropertyModel,
|
||||||
|
message: 'Model updated successfully',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new model
|
||||||
|
const modelId = createId('model')
|
||||||
|
|
||||||
|
const { data: newModel, error: createError } = await supabase
|
||||||
|
.from('properties_models')
|
||||||
|
.insert({
|
||||||
|
id: modelId,
|
||||||
|
property_id: propertyId,
|
||||||
|
name: `${property.name} - Editor`,
|
||||||
|
version: 1,
|
||||||
|
draft: true,
|
||||||
|
scene_graph: sceneGraph as any,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (createError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: createError.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: newModel as PropertyModel,
|
||||||
|
message: 'Model created successfully',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to save property model',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for property model (scene) loading and auto-saving
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { usePropertyStore } from '../properties/store'
|
||||||
|
import { getPropertyModel, savePropertyModel } from './actions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the scene when a property becomes active
|
||||||
|
* Saves changes automatically with debouncing
|
||||||
|
*/
|
||||||
|
export function usePropertyScene() {
|
||||||
|
// Subscribe to property store
|
||||||
|
const activeProperty = usePropertyStore((state) => state.activeProperty)
|
||||||
|
const isLoadingProperty = usePropertyStore((state) => state.isLoading)
|
||||||
|
|
||||||
|
const lastPropertyIdRef = useRef<string | null>(null)
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout>()
|
||||||
|
const isSavingRef = useRef(false)
|
||||||
|
const currentPropertyIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Extract property ID for dependency tracking
|
||||||
|
const propertyId = activeProperty?.id ?? null
|
||||||
|
const propertyName = activeProperty?.name ?? null
|
||||||
|
|
||||||
|
// Load scene when active property changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingProperty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!propertyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if same property
|
||||||
|
if (lastPropertyIdRef.current === propertyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPropertyIdRef.current = propertyId
|
||||||
|
|
||||||
|
// Load the property's scene
|
||||||
|
async function loadScene() {
|
||||||
|
try {
|
||||||
|
const result = await getPropertyModel(propertyId || '')
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fall back to clear scene
|
||||||
|
useScene.getState().clearScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset editor state after loading/clearing scene
|
||||||
|
useEditor.getState().setPhase('site')
|
||||||
|
useViewer.getState().setSelection({
|
||||||
|
buildingId: null,
|
||||||
|
levelId: null,
|
||||||
|
selectedIds: [],
|
||||||
|
zoneId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadScene()
|
||||||
|
}, [propertyId, isLoadingProperty])
|
||||||
|
|
||||||
|
// Auto-save scene changes with debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!propertyId) {
|
||||||
|
currentPropertyIdRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPropertyIdRef.current = propertyId
|
||||||
|
|
||||||
|
// Subscribe to any scene changes
|
||||||
|
// Use JSON stringification to detect any node changes, not just count
|
||||||
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
||||||
|
|
||||||
|
const unsubscribe = useScene.subscribe((state) => {
|
||||||
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
||||||
|
|
||||||
|
// Only trigger save if nodes actually changed
|
||||||
|
if (currentNodesSnapshot === lastNodesSnapshot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNodesSnapshot = currentNodesSnapshot
|
||||||
|
const nodes = state.nodes
|
||||||
|
|
||||||
|
// Skip if currently saving
|
||||||
|
if (isSavingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce save by 2 seconds
|
||||||
|
saveTimeoutRef.current = setTimeout(async () => {
|
||||||
|
// Get the current property ID at save time (not the captured value)
|
||||||
|
const currentPropertyId = currentPropertyIdRef.current
|
||||||
|
if (!currentPropertyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootNodeIds = useScene.getState().rootNodeIds
|
||||||
|
const sceneGraph = { nodes, rootNodeIds }
|
||||||
|
|
||||||
|
isSavingRef.current = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await savePropertyModel(currentPropertyId, sceneGraph)
|
||||||
|
} finally {
|
||||||
|
isSavingRef.current = false
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current)
|
||||||
|
}
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [propertyId])
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
getActiveProperty,
|
getActiveProperty,
|
||||||
getUserProperties,
|
getUserProperties,
|
||||||
@@ -116,8 +116,9 @@ export function useActiveProperty() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const isInitialFetchRef = useRef(true)
|
||||||
|
|
||||||
const fetchActiveProperty = useCallback(async () => {
|
const fetchActiveProperty = useCallback(async (allowAutoSelect = false) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -128,7 +129,9 @@ export function useActiveProperty() {
|
|||||||
setActivePropertyState(result.data || null)
|
setActivePropertyState(result.data || null)
|
||||||
|
|
||||||
// If no active property is set, automatically set the first property as active
|
// If no active property is set, automatically set the first property as active
|
||||||
if (!result.data) {
|
// Only do this on initial mount to avoid interfering with property selection/creation
|
||||||
|
if (!result.data && allowAutoSelect) {
|
||||||
|
console.log('[useActiveProperty] No active property, checking if we should auto-select')
|
||||||
const propertiesResult = await getUserProperties()
|
const propertiesResult = await getUserProperties()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -136,15 +139,19 @@ export function useActiveProperty() {
|
|||||||
propertiesResult.data &&
|
propertiesResult.data &&
|
||||||
propertiesResult.data.length > 0
|
propertiesResult.data.length > 0
|
||||||
) {
|
) {
|
||||||
|
console.log('[useActiveProperty] Found properties, auto-selecting first one')
|
||||||
const firstProperty = propertiesResult.data[0]
|
const firstProperty = propertiesResult.data[0]
|
||||||
if (firstProperty) {
|
if (firstProperty) {
|
||||||
const setActiveResult = await setActivePropertyAction(firstProperty.id)
|
const setActiveResult = await setActivePropertyAction(firstProperty.id)
|
||||||
|
|
||||||
if (setActiveResult.success) {
|
if (setActiveResult.success) {
|
||||||
|
console.log('[useActiveProperty] Auto-selected property:', firstProperty.name)
|
||||||
setActivePropertyState(firstProperty)
|
setActivePropertyState(firstProperty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!result.data && !allowAutoSelect) {
|
||||||
|
console.log('[useActiveProperty] No active property but auto-select is disabled')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to fetch active property')
|
setError(result.error || 'Failed to fetch active property')
|
||||||
@@ -162,20 +169,44 @@ export function useActiveProperty() {
|
|||||||
async (propertyId: string | null) => {
|
async (propertyId: string | null) => {
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
|
setIsLoading(true)
|
||||||
const result = await setActivePropertyAction(propertyId)
|
const result = await setActivePropertyAction(propertyId)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Fetch the updated active property
|
|
||||||
if (propertyId) {
|
if (propertyId) {
|
||||||
await fetchActiveProperty()
|
// Fetch the property data and set it immediately
|
||||||
|
console.log('[useActiveProperty] Fetching property data for ID:', propertyId)
|
||||||
|
const propertiesResult = await getUserProperties()
|
||||||
|
|
||||||
|
if (propertiesResult.success && propertiesResult.data) {
|
||||||
|
const selectedProperty = propertiesResult.data.find(p => p.id === propertyId)
|
||||||
|
if (selectedProperty) {
|
||||||
|
console.log('[useActiveProperty] Found property, setting as active:', selectedProperty.name)
|
||||||
|
console.log('[useActiveProperty] Current isLoading state:', isLoading)
|
||||||
|
setActivePropertyState(selectedProperty)
|
||||||
|
setIsLoading(false)
|
||||||
|
console.log('[useActiveProperty] Set isLoading to false')
|
||||||
|
} else {
|
||||||
|
console.error('[useActiveProperty] Property not found in user properties')
|
||||||
|
// Fall back to refetch
|
||||||
|
await fetchActiveProperty(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[useActiveProperty] Failed to fetch properties')
|
||||||
|
// Fall back to refetch
|
||||||
|
await fetchActiveProperty(false)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setActivePropertyState(null)
|
setActivePropertyState(null)
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(result.error || 'Failed to update active property')
|
console.error(result.error || 'Failed to update active property')
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err instanceof Error ? err.message : 'An unexpected error occurred')
|
console.error(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||||
|
setIsLoading(false)
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
@@ -184,7 +215,12 @@ export function useActiveProperty() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchActiveProperty()
|
// Only allow auto-select on the initial mount
|
||||||
|
const allowAutoSelect = isInitialFetchRef.current
|
||||||
|
if (isInitialFetchRef.current) {
|
||||||
|
isInitialFetchRef.current = false
|
||||||
|
}
|
||||||
|
fetchActiveProperty(allowAutoSelect)
|
||||||
}, [fetchActiveProperty])
|
}, [fetchActiveProperty])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Property store - Zustand store for property state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Property } from './types'
|
||||||
|
import {
|
||||||
|
getActiveProperty,
|
||||||
|
getUserProperties,
|
||||||
|
setActiveProperty as setActivePropertyAction,
|
||||||
|
} from './actions'
|
||||||
|
|
||||||
|
interface PropertyStore {
|
||||||
|
// State
|
||||||
|
activeProperty: Property | null
|
||||||
|
properties: Property[]
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchProperties: () => Promise<void>
|
||||||
|
fetchActiveProperty: () => Promise<void>
|
||||||
|
setActiveProperty: (propertyId: string) => Promise<void>
|
||||||
|
initialize: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePropertyStore = create<PropertyStore>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
activeProperty: null,
|
||||||
|
properties: [],
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch all properties
|
||||||
|
fetchProperties: async () => {
|
||||||
|
const result = await getUserProperties()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
set({ properties: result.data || [], error: null })
|
||||||
|
} else {
|
||||||
|
set({ error: result.error || 'Failed to fetch properties', properties: [] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch the active property from database
|
||||||
|
fetchActiveProperty: async () => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
|
||||||
|
const result = await getActiveProperty()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
set({
|
||||||
|
activeProperty: result.data || null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no active property, auto-select the first one
|
||||||
|
if (!result.data) {
|
||||||
|
const propertiesResult = await getUserProperties()
|
||||||
|
if (propertiesResult.success && propertiesResult.data && propertiesResult.data.length > 0) {
|
||||||
|
const firstProperty = propertiesResult.data[0]
|
||||||
|
if (firstProperty) {
|
||||||
|
await get().setActiveProperty(firstProperty.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: result.error || 'Failed to fetch active property',
|
||||||
|
activeProperty: null,
|
||||||
|
isLoading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set active property
|
||||||
|
setActiveProperty: async (propertyId: string) => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
const result = await setActivePropertyAction(propertyId)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Fetch properties to get the full property object
|
||||||
|
const propertiesResult = await getUserProperties()
|
||||||
|
|
||||||
|
if (propertiesResult.success && propertiesResult.data) {
|
||||||
|
const selectedProperty = propertiesResult.data.find(p => p.id === propertyId)
|
||||||
|
|
||||||
|
if (selectedProperty) {
|
||||||
|
set({
|
||||||
|
activeProperty: selectedProperty,
|
||||||
|
properties: propertiesResult.data,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Property not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to fetch properties'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: result.error || 'Failed to set active property'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Initialize - fetch both properties and active property
|
||||||
|
initialize: async () => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
await Promise.all([
|
||||||
|
get().fetchProperties(),
|
||||||
|
get().fetchActiveProperty(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user