auth flow + property switching

This commit is contained in:
wass08
2026-02-11 10:48:24 +09:00
parent c1ff61ad5b
commit 4f9c4655e6
9 changed files with 589 additions and 17 deletions
+2 -1
View File
@@ -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(),
])
},
}))