diff --git a/apps/editor/app/editor/[propertyId]/page.tsx b/apps/editor/app/editor/[propertyId]/page.tsx
new file mode 100644
index 00000000..47954a0a
--- /dev/null
+++ b/apps/editor/app/editor/[propertyId]/page.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import Editor from '@/components/editor'
+import { useParams } from 'next/navigation'
+import { useEffect, useLayoutEffect } from 'react'
+import { usePropertyStore } from '@/features/community/lib/properties/store'
+import { useAuth } from '@/features/community/lib/auth/hooks'
+
+export default function EditorPage() {
+ const params = useParams()
+ const propertyId = params.propertyId as string
+ const { isAuthenticated } = useAuth()
+ const setActiveProperty = usePropertyStore((state) => state.setActiveProperty)
+
+ // Use layoutEffect to set active property BEFORE the editor renders and hooks run
+ useLayoutEffect(() => {
+ // For authenticated users with cloud properties, set the active property from URL
+ if (isAuthenticated && propertyId && !propertyId.startsWith('local_')) {
+ setActiveProperty(propertyId)
+ }
+ }, [propertyId, isAuthenticated, setActiveProperty])
+
+ return (
+
+ )
+}
diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx
index e9e2c256..a7013249 100644
--- a/apps/editor/app/page.tsx
+++ b/apps/editor/app/page.tsx
@@ -1,11 +1,5 @@
-import Editor from '../components/editor'
+import CommunityHub from '@/features/community/components/community-hub'
export default function Home() {
- return (
-
- )
+ return
}
diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx
index bd7a541f..a44851c9 100644
--- a/apps/editor/app/viewer/[id]/page.tsx
+++ b/apps/editor/app/viewer/[id]/page.tsx
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'
import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
+import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions'
export default function ViewerPage() {
const params = useParams()
@@ -16,25 +17,47 @@ export default function ViewerPage() {
const setScene = useScene((state) => state.setScene)
useEffect(() => {
- const loadDemo = async () => {
+ const loadContent = async () => {
try {
- const response = await fetch(`/demos/${id}.json`)
- if (!response.ok) {
- throw new Error(`Demo "${id}" not found`)
- }
- const data = await response.json()
- if (data.nodes && data.rootNodeIds) {
- setScene(data.nodes, data.rootNodeIds)
- initSpatialGridSync()
+ // Check if it's a demo file (starts with 'demo_')
+ if (id.startsWith('demo_')) {
+ const response = await fetch(`/demos/${id}.json`)
+ if (!response.ok) {
+ throw new Error(`Demo "${id}" not found`)
+ }
+ const data = await response.json()
+ if (data.nodes && data.rootNodeIds) {
+ setScene(data.nodes, data.rootNodeIds)
+ initSpatialGridSync()
+ }
+ } else {
+ // Load from database (public property)
+ const result = await getPropertyModelPublic(id)
+
+ if (result.success && result.data) {
+ const { model } = result.data
+
+ if (model?.scene_graph) {
+ const { nodes, rootNodeIds } = model.scene_graph
+ setScene(nodes, rootNodeIds)
+ initSpatialGridSync()
+ }
+
+ // Increment view count
+ await incrementPropertyViews(id)
+ } else {
+ throw new Error(result.error || 'Property not found')
+ }
}
+
setLoading(false)
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load demo')
+ setError(err instanceof Error ? err.message : 'Failed to load content')
setLoading(false)
}
}
- loadDemo()
+ loadContent()
}, [id, setScene])
if (loading) {
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index e27abda6..a4905ea0 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -5,6 +5,8 @@ import { Viewer } from '@pascal-app/viewer'
import { useKeyboard } from '@/hooks/use-keyboard'
import useEditor from '@/store/use-editor'
import { usePropertyScene } from '@/features/community/lib/models/hooks'
+import { useLocalPropertyScene } from '@/features/community/lib/local-storage/hooks'
+import { useAuth } from '@/features/community/lib/auth/hooks'
import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
@@ -28,8 +30,24 @@ initSpaceDetectionSync(useScene, useEditor)
// Initialize SFX bus to connect events to sound effects
initSFXBus()
-export default function Editor() {
+interface EditorProps {
+ propertyId?: string
+}
+
+export default function Editor({ propertyId }: EditorProps) {
useKeyboard()
+ const { isAuthenticated } = useAuth()
+
+ // Determine which mode to use
+ const isLocalProperty = propertyId?.startsWith('local_')
+ const shouldUseCloud = isAuthenticated && !isLocalProperty
+ const shouldUseLocal = !shouldUseCloud && !!propertyId
+
+ // Call hooks unconditionally (hooks internally check if they should activate)
+ // Cloud hook activates when there's an activeProperty in the store
+ usePropertyScene()
+ // Local hook activates when propertyId is provided and starts with 'local_'
+ useLocalPropertyScene(shouldUseLocal ? propertyId : undefined)
return (
@@ -43,7 +61,7 @@ export default function Editor() {
-
+
diff --git a/apps/editor/features/community/components/cloud-save-button.tsx b/apps/editor/features/community/components/cloud-save-button.tsx
index 0aebf429..dc42a7fc 100644
--- a/apps/editor/features/community/components/cloud-save-button.tsx
+++ b/apps/editor/features/community/components/cloud-save-button.tsx
@@ -1,23 +1,31 @@
'use client'
-import { Cloud } from 'lucide-react'
+import { Cloud, Home } from 'lucide-react'
import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks'
import { usePropertyStore } from '../lib/properties/store'
import { ProfileDropdown } from './profile-dropdown'
-import { PropertyDropdown } from './property-dropdown'
import { SignInDialog } from './sign-in-dialog'
+interface CloudSaveButtonProps {
+ propertyId?: string
+}
+
/**
* CloudSaveButton - Shows authentication state and property management
*
- * Not authenticated: Shows "Save to cloud" button
- * Authenticated: Shows PropertyDropdown and ProfileDropdown
+ * Guest with local property: Shows "Save to cloud" button
+ * Guest without property: Shows "Home" button
+ * Authenticated: Shows ProfileDropdown
*/
-export function CloudSaveButton() {
+export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
const { isAuthenticated, isLoading } = useAuth()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const initialize = usePropertyStore(state => state.initialize)
+ const router = useRouter()
+
+ const isLocalProperty = propertyId?.startsWith('local_')
// Initialize property store when authenticated
useEffect(() => {
@@ -36,7 +44,8 @@ export function CloudSaveButton() {
)
}
- if (!isAuthenticated) {
+ // Guest user with local property
+ if (!isAuthenticated && isLocalProperty) {
return (
<>
@@ -53,12 +62,25 @@ export function CloudSaveButton() {
)
}
+ // Guest user (no property context or browsing)
+ if (!isAuthenticated) {
+ return (
+
+
+
+ )
+ }
+
+ // Authenticated user
return (
)
}
diff --git a/apps/editor/features/community/components/community-hub.tsx b/apps/editor/features/community/components/community-hub.tsx
new file mode 100644
index 00000000..00954bc4
--- /dev/null
+++ b/apps/editor/features/community/components/community-hub.tsx
@@ -0,0 +1,217 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useAuth } from '../lib/auth/hooks'
+import { useRouter } from 'next/navigation'
+import { SignInDialog } from './sign-in-dialog'
+import { PropertyGrid } from './property-grid'
+import { CreatePropertyButton } from './create-property-button'
+import { ProfileDropdown } from './profile-dropdown'
+import { NewPropertyDialog } from './new-property-dialog'
+import { getPublicProperties, getUserProperties } from '../lib/properties/actions'
+import { getLocalProperties, createLocalProperty } from '../lib/local-storage/property-store'
+import type { Property } from '../lib/properties/types'
+import type { LocalProperty } from '../lib/local-storage/property-store'
+
+export default function CommunityHub() {
+ const { isAuthenticated, isLoading: authLoading, user } = useAuth()
+ const router = useRouter()
+ const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
+ const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
+ const [localPropertyToSave, setLocalPropertyToSave] = useState
(null)
+ const [publicProperties, setPublicProperties] = useState([])
+ const [userProperties, setUserProperties] = useState([])
+ const [localProperties, setLocalProperties] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ async function loadProperties() {
+ setLoading(true)
+
+ // Load public properties (always)
+ const publicResult = await getPublicProperties()
+ if (publicResult.success) {
+ setPublicProperties(publicResult.data || [])
+ }
+
+ // Load user properties if authenticated
+ if (isAuthenticated) {
+ const userResult = await getUserProperties()
+ if (userResult.success) {
+ setUserProperties(userResult.data || [])
+ }
+ }
+
+ // Always load local properties
+ setLocalProperties(getLocalProperties())
+
+ setLoading(false)
+ }
+
+ if (!authLoading) {
+ loadProperties()
+ }
+ }, [isAuthenticated, authLoading])
+
+ const handleCreateProperty = async () => {
+ if (!isAuthenticated) {
+ // Create local property for guest
+ const property = createLocalProperty('Untitled Property')
+ router.push(`/editor/${property.id}`)
+ } else {
+ // Open property creation dialog for authenticated users
+ setIsNewPropertyDialogOpen(true)
+ }
+ }
+
+ const handlePropertyCreated = async (propertyId: string) => {
+ // If this was a local property being saved, delete it from localStorage
+ if (localPropertyToSave) {
+ const { deleteLocalProperty } = await import('../lib/local-storage/property-store')
+ deleteLocalProperty(localPropertyToSave.id)
+ setLocalProperties(getLocalProperties())
+ setLocalPropertyToSave(null)
+ }
+
+ // Reload properties and navigate to the new property
+ const result = await getUserProperties()
+ if (result.success) {
+ setUserProperties(result.data || [])
+ }
+ router.push(`/editor/${propertyId}`)
+ }
+
+ const handleSaveLocalToCloud = (localProperty: LocalProperty) => {
+ setLocalPropertyToSave(localProperty)
+ setIsNewPropertyDialogOpen(true)
+ }
+
+ const handlePropertyClick = (propertyId: string) => {
+ router.push(`/editor/${propertyId}`)
+ }
+
+ const handleViewProperty = (propertyId: string) => {
+ router.push(`/viewer/${propertyId}`)
+ }
+
+
+ if (authLoading || loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
Hub
+ {!isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* User's Properties Section */}
+ {isAuthenticated && (userProperties.length > 0 || localProperties.length > 0) && (
+
+
+
My Properties
+
+
+ {
+ // Reload properties after settings update
+ if (!authLoading) {
+ getUserProperties().then((result) => {
+ if (result.success) {
+ setUserProperties(result.data || [])
+ }
+ })
+ }
+ }}
+ />
+
+ )}
+
+ {/* Local Properties Section (Guest Users) */}
+ {!isAuthenticated && localProperties.length > 0 && (
+
+
+
My Local Projects
+
+
+
+
+ )}
+
+ {/* Create First Property CTA */}
+ {!isAuthenticated && localProperties.length === 0 && (
+
+ Get Started
+
+ Create your first property to start designing
+
+
+
+ )}
+
+ {/* Public Properties Section */}
+
+ Community Properties
+ {publicProperties.length > 0 ? (
+
+ ) : (
+
+ No public properties yet
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/editor/features/community/components/create-property-button.tsx b/apps/editor/features/community/components/create-property-button.tsx
new file mode 100644
index 00000000..fd48a761
--- /dev/null
+++ b/apps/editor/features/community/components/create-property-button.tsx
@@ -0,0 +1,19 @@
+'use client'
+
+import { Plus } from 'lucide-react'
+
+interface CreatePropertyButtonProps {
+ onCreateProperty: () => void
+}
+
+export function CreatePropertyButton({ onCreateProperty }: CreatePropertyButtonProps) {
+ return (
+
+ )
+}
diff --git a/apps/editor/features/community/components/local-property-migration-dialog.tsx b/apps/editor/features/community/components/local-property-migration-dialog.tsx
new file mode 100644
index 00000000..c1b6bd8f
--- /dev/null
+++ b/apps/editor/features/community/components/local-property-migration-dialog.tsx
@@ -0,0 +1,83 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/primitives/dialog'
+import type { LocalProperty } from '../lib/local-storage/property-store'
+
+interface LocalPropertyMigrationDialogProps {
+ localProperties: LocalProperty[]
+ open: boolean
+ onMigrate: () => Promise
+ onSkip: () => void
+}
+
+export function LocalPropertyMigrationDialog({
+ localProperties,
+ open,
+ onMigrate,
+ onSkip,
+}: LocalPropertyMigrationDialogProps) {
+ const [isMigrating, setIsMigrating] = useState(false)
+
+ const handleMigrate = async () => {
+ setIsMigrating(true)
+ try {
+ await onMigrate()
+ } finally {
+ setIsMigrating(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/editor/features/community/components/new-property-dialog.tsx b/apps/editor/features/community/components/new-property-dialog.tsx
index 2e73e123..2e73d6e6 100644
--- a/apps/editor/features/community/components/new-property-dialog.tsx
+++ b/apps/editor/features/community/components/new-property-dialog.tsx
@@ -4,12 +4,18 @@ import { X } from 'lucide-react'
import { useState } from 'react'
import { createProperty } from '../lib/properties/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
+import { Switch } from '@/components/ui/primitives/switch'
import { GoogleAddressSearch } from './google-address-search'
interface NewPropertyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: (propertyId: string) => void
+ localPropertyData?: {
+ id: string
+ name: string
+ sceneGraph: any
+ }
}
interface AddressData {
@@ -26,8 +32,9 @@ interface AddressData {
/**
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
*/
-export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
+export function NewPropertyDialog({ open, onOpenChange, onSuccess, localPropertyData }: NewPropertyDialogProps) {
const [address, setAddress] = useState(null)
+ const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState(null)
@@ -58,11 +65,14 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
state: address.state,
postalCode: address.postalCode,
country: address.country || 'US',
+ isPrivate,
+ sceneGraph: localPropertyData?.sceneGraph,
})
if (result.success && result.data) {
onOpenChange(false)
setAddress(null)
+ setIsPrivate(false)
onSuccess?.(result.data.id)
} else {
setError(result.error || 'Failed to create property')
@@ -78,6 +88,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
if (!isCreating) {
onOpenChange(false)
setAddress(null)
+ setIsPrivate(false)
setError(null)
}
}
@@ -112,6 +123,31 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
)}
+ {/* Privacy Toggle */}
+
+
+
Privacy
+
+ {isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
+
+
+
+ Public
+ setIsPrivate(!checked)} />
+
+
+
+ {localPropertyData && (
+
+
+ Saving local property: {localPropertyData.name}
+
+
+ Your building data will be preserved
+
+
+ )}
+
{error && (
{error}
diff --git a/apps/editor/features/community/components/property-grid.tsx b/apps/editor/features/community/components/property-grid.tsx
new file mode 100644
index 00000000..21f2800c
--- /dev/null
+++ b/apps/editor/features/community/components/property-grid.tsx
@@ -0,0 +1,234 @@
+'use client'
+
+import { Eye, Heart, Settings } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import type { Property } from '../lib/properties/types'
+import type { LocalProperty } from '../lib/local-storage/property-store'
+import { PropertySettingsDialog } from './property-settings-dialog'
+import { getUserPropertyLikes, togglePropertyLike } from '../lib/properties/actions'
+import { useAuth } from '../lib/auth/hooks'
+
+interface PropertyGridProps {
+ properties: (Property | LocalProperty)[]
+ onPropertyClick: (id: string) => void
+ onViewClick?: (id: string) => void
+ onSaveToCloud?: (property: LocalProperty) => void
+ showOwner: boolean
+ isLocal?: boolean
+ canEdit?: boolean
+ onUpdate?: () => void
+}
+
+function isLocalProperty(prop: Property | LocalProperty): prop is LocalProperty {
+ return 'is_local' in prop && prop.is_local === true
+}
+
+export function PropertyGrid({
+ properties,
+ onPropertyClick,
+ onViewClick,
+ onSaveToCloud,
+ showOwner,
+ isLocal = false,
+ canEdit = false,
+ onUpdate,
+}: PropertyGridProps) {
+ const { isAuthenticated } = useAuth()
+ const [settingsProperty, setSettingsProperty] = useState
(null)
+ const [userLikes, setUserLikes] = useState>({})
+ const [likeCounts, setLikeCounts] = useState>({})
+
+ // Initialize like counts from properties
+ useEffect(() => {
+ const counts: Record = {}
+ properties.forEach((prop) => {
+ if (!isLocalProperty(prop)) {
+ counts[prop.id] = prop.likes
+ }
+ })
+ setLikeCounts(counts)
+ }, [properties])
+
+ // Fetch which properties the user has liked
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setUserLikes({})
+ return
+ }
+
+ const propertyIds = properties
+ .filter((p) => !isLocalProperty(p))
+ .map((p) => p.id)
+
+ if (propertyIds.length === 0) return
+
+ getUserPropertyLikes(propertyIds).then((result) => {
+ if (result.success && result.data) {
+ setUserLikes(result.data)
+ }
+ })
+ }, [properties, isAuthenticated])
+
+ const handleSettingsClick = (e: React.MouseEvent, property: Property | LocalProperty) => {
+ e.stopPropagation()
+ if (!isLocalProperty(property)) {
+ setSettingsProperty(property)
+ }
+ }
+
+ const handleViewClick = (e: React.MouseEvent, propertyId: string) => {
+ e.stopPropagation()
+ onViewClick?.(propertyId)
+ }
+
+ const handleLikeClick = async (e: React.MouseEvent, propertyId: string) => {
+ e.stopPropagation()
+
+ if (!isAuthenticated) {
+ // Could show a sign-in prompt here
+ return
+ }
+
+ // Optimistic update
+ const wasLiked = userLikes[propertyId] || false
+ const currentCount = likeCounts[propertyId] || 0
+
+ setUserLikes((prev) => ({ ...prev, [propertyId]: !wasLiked }))
+ setLikeCounts((prev) => ({
+ ...prev,
+ [propertyId]: wasLiked ? currentCount - 1 : currentCount + 1
+ }))
+
+ // Call server action
+ const result = await togglePropertyLike(propertyId)
+
+ if (result.success && result.data) {
+ // Update with actual values from server
+ setUserLikes((prev) => ({ ...prev, [propertyId]: result.data.liked }))
+ setLikeCounts((prev) => ({ ...prev, [propertyId]: result.data.likes }))
+ } else {
+ // Revert on error
+ setUserLikes((prev) => ({ ...prev, [propertyId]: wasLiked }))
+ setLikeCounts((prev) => ({ ...prev, [propertyId]: currentCount }))
+ }
+ }
+
+ return (
+ <>
+
+ {properties.map((property) => (
+
+ ))}
+
+
+ {/* Settings Dialog */}
+ {settingsProperty && (
+ !open && setSettingsProperty(null)}
+ onUpdate={onUpdate}
+ onDelete={() => {
+ setSettingsProperty(null)
+ onUpdate?.()
+ }}
+ />
+ )}
+ >
+ )
+}
diff --git a/apps/editor/features/community/components/property-settings-dialog.tsx b/apps/editor/features/community/components/property-settings-dialog.tsx
new file mode 100644
index 00000000..25c6111c
--- /dev/null
+++ b/apps/editor/features/community/components/property-settings-dialog.tsx
@@ -0,0 +1,242 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/primitives/dialog'
+import { Switch } from '@/components/ui/primitives/switch'
+import { updatePropertyAddress, updatePropertyPrivacy, deleteProperty } from '../lib/properties/actions'
+import type { Property } from '../lib/properties/types'
+
+interface PropertySettingsDialogProps {
+ property: Property
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onUpdate?: () => void
+ onDelete?: () => void
+}
+
+export function PropertySettingsDialog({
+ property,
+ open,
+ onOpenChange,
+ onUpdate,
+ onDelete,
+}: PropertySettingsDialogProps) {
+ const [loading, setLoading] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [isPrivate, setIsPrivate] = useState(property.is_private)
+ const [address, setAddress] = useState({
+ street_number: property.address.street_number || '',
+ route: property.address.route || '',
+ city: property.address.city || '',
+ state: property.address.state || '',
+ postal_code: property.address.postal_code || '',
+ country: property.address.country || 'US',
+ })
+
+ const handleSave = async () => {
+ setLoading(true)
+ try {
+ // Update privacy if changed
+ if (isPrivate !== property.is_private) {
+ const privacyResult = await updatePropertyPrivacy(property.id, isPrivate)
+ if (!privacyResult.success) {
+ alert(`Failed to update privacy: ${privacyResult.error}`)
+ setLoading(false)
+ return
+ }
+ }
+
+ // Update address if changed
+ const addressChanged =
+ address.street_number !== (property.address.street_number || '') ||
+ address.route !== (property.address.route || '') ||
+ address.city !== (property.address.city || '') ||
+ address.state !== (property.address.state || '') ||
+ address.postal_code !== (property.address.postal_code || '') ||
+ address.country !== (property.address.country || 'US')
+
+ if (addressChanged) {
+ const addressResult = await updatePropertyAddress(property.id, address)
+ if (!addressResult.success) {
+ alert(`Failed to update address: ${addressResult.error}`)
+ setLoading(false)
+ return
+ }
+ }
+
+ onUpdate?.()
+ onOpenChange(false)
+ } catch (error) {
+ alert('Failed to save settings')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) {
+ return
+ }
+
+ setIsDeleting(true)
+ try {
+ const result = await deleteProperty(property.id)
+ if (result.success) {
+ onDelete?.()
+ onOpenChange(false)
+ } else {
+ alert(`Failed to delete property: ${result.error}`)
+ }
+ } catch (error) {
+ alert('Failed to delete property')
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/editor/features/community/lib/local-storage/hooks.ts b/apps/editor/features/community/lib/local-storage/hooks.ts
new file mode 100644
index 00000000..b9258d4b
--- /dev/null
+++ b/apps/editor/features/community/lib/local-storage/hooks.ts
@@ -0,0 +1,93 @@
+'use client'
+
+import { initSpatialGridSync, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useRef } from 'react'
+import useEditor from '@/store/use-editor'
+import { getLocalProperty, updateLocalPropertyScene } from './property-store'
+
+/**
+ * Hook for local property scene management (guest users)
+ * Loads scene from localStorage and auto-saves changes
+ */
+export function useLocalPropertyScene(propertyId?: string) {
+ const saveTimeoutRef = useRef(undefined)
+ const currentPropertyIdRef = useRef(null)
+ const lastPropertyIdRef = useRef(null)
+
+ // Load scene when property ID changes
+ useEffect(() => {
+ if (!propertyId || !propertyId.startsWith('local_')) {
+ return
+ }
+
+ if (lastPropertyIdRef.current === propertyId) {
+ return
+ }
+
+ lastPropertyIdRef.current = propertyId
+ currentPropertyIdRef.current = propertyId
+
+ const property = getLocalProperty(propertyId)
+
+ if (property?.scene_graph) {
+ const { nodes, rootNodeIds } = property.scene_graph
+ useScene.getState().setScene(nodes, rootNodeIds)
+ initSpatialGridSync()
+ } else {
+ useScene.getState().clearScene()
+ }
+
+ useEditor.getState().setPhase('site')
+ useViewer.getState().setSelection({
+ buildingId: null,
+ levelId: null,
+ selectedIds: [],
+ zoneId: null,
+ })
+ }, [propertyId])
+
+ // Auto-save to localStorage with debouncing
+ useEffect(() => {
+ if (!propertyId || !propertyId.startsWith('local_')) {
+ currentPropertyIdRef.current = null
+ return
+ }
+
+ currentPropertyIdRef.current = propertyId
+ let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
+
+ const unsubscribe = useScene.subscribe((state) => {
+ const currentNodesSnapshot = JSON.stringify(state.nodes)
+
+ if (currentNodesSnapshot === lastNodesSnapshot) {
+ return
+ }
+
+ lastNodesSnapshot = currentNodesSnapshot
+ const nodes = state.nodes
+
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current)
+ }
+
+ // Debounce save by 1 second (faster than cloud save)
+ saveTimeoutRef.current = setTimeout(() => {
+ const currentId = currentPropertyIdRef.current
+ if (!currentId) return
+
+ const rootNodeIds = useScene.getState().rootNodeIds
+ const sceneGraph = { nodes, rootNodeIds }
+
+ updateLocalPropertyScene(currentId, sceneGraph)
+ }, 1000)
+ })
+
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current)
+ }
+ unsubscribe()
+ }
+ }, [propertyId])
+}
diff --git a/apps/editor/features/community/lib/local-storage/property-store.ts b/apps/editor/features/community/lib/local-storage/property-store.ts
new file mode 100644
index 00000000..3730054e
--- /dev/null
+++ b/apps/editor/features/community/lib/local-storage/property-store.ts
@@ -0,0 +1,89 @@
+/**
+ * Local storage management for guest users
+ * Stores properties and scenes in browser localStorage
+ */
+
+import { createId } from '../utils/id-generator'
+
+export interface SceneGraph {
+ nodes: Record
+ rootNodeIds: string[]
+}
+
+export interface LocalProperty {
+ id: string // Format: 'local_property_xyz'
+ name: string
+ created_at: string
+ updated_at: string
+ scene_graph: SceneGraph | null
+ is_local: true
+}
+
+const LOCAL_PROPERTIES_KEY = 'pascal_local_properties'
+
+export function getLocalProperties(): LocalProperty[] {
+ if (typeof window === 'undefined') return []
+
+ try {
+ const stored = localStorage.getItem(LOCAL_PROPERTIES_KEY)
+ return stored ? JSON.parse(stored) : []
+ } catch (error) {
+ console.error('Failed to load local properties:', error)
+ return []
+ }
+}
+
+export function getLocalProperty(id: string): LocalProperty | null {
+ const properties = getLocalProperties()
+ return properties.find((p) => p.id === id) || null
+}
+
+export function saveLocalProperty(property: LocalProperty): void {
+ const properties = getLocalProperties()
+ const index = properties.findIndex((p) => p.id === property.id)
+
+ if (index >= 0) {
+ properties[index] = { ...property, updated_at: new Date().toISOString() }
+ } else {
+ properties.push(property)
+ }
+
+ localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties))
+}
+
+export function createLocalProperty(name: string): LocalProperty {
+ const property: LocalProperty = {
+ id: createId('local_property'),
+ name,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ scene_graph: null,
+ is_local: true,
+ }
+
+ saveLocalProperty(property)
+ return property
+}
+
+export function deleteLocalProperty(id: string): void {
+ const properties = getLocalProperties().filter((p) => p.id !== id)
+ localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties))
+}
+
+export function updateLocalPropertyScene(id: string, sceneGraph: SceneGraph): void {
+ const property = getLocalProperty(id)
+ if (property) {
+ property.scene_graph = sceneGraph
+ saveLocalProperty(property)
+ }
+}
+
+export function migrateLocalPropertiesToCloud(userId: string): LocalProperty[] {
+ // Return local properties that need to be migrated
+ // Actual migration handled by separate function
+ return getLocalProperties()
+}
+
+export function clearLocalProperties(): void {
+ localStorage.removeItem(LOCAL_PROPERTIES_KEY)
+}
diff --git a/apps/editor/features/community/lib/properties/actions.ts b/apps/editor/features/community/lib/properties/actions.ts
index 81f3c5f0..f424505c 100644
--- a/apps/editor/features/community/lib/properties/actions.ts
+++ b/apps/editor/features/community/lib/properties/actions.ts
@@ -42,6 +42,7 @@ export async function getUserProperties(): Promise> {
address:properties_addresses(*)
`)
.eq('owner_id', session.user.id)
+ .order('created_at', { ascending: false })
if (error) {
return {
@@ -218,6 +219,7 @@ export async function createProperty(params: CreatePropertyParams): Promise> {
+ try {
+ const supabase = await createServerSupabaseClient()
+
+ const { data, error } = await supabase
+ .from('properties')
+ .select(`
+ *,
+ address:properties_addresses(*)
+ `)
+ .eq('is_private', false)
+ .order('views', { ascending: false })
+ .limit(50)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ data: [],
+ }
+ }
+
+ return {
+ success: true,
+ data: data as Property[],
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch public properties',
+ data: [],
+ }
+ }
+}
+
+/**
+ * Get a property model for viewing
+ * Allows viewing if: property is public OR user owns the property
+ */
+export async function getPropertyModelPublic(propertyId: string): Promise<
+ ActionResult<{ property: Property; model: any | null }>
+> {
+ try {
+ const session = await getSession()
+ const supabase = await createServerSupabaseClient()
+
+ // Get the property (without privacy filter first)
+ const { data: property, error: propertyError } = await supabase
+ .from('properties')
+ .select(`
+ *,
+ address:properties_addresses(*)
+ `)
+ .eq('id', propertyId)
+ .single()
+
+ if (propertyError || !property) {
+ return {
+ success: false,
+ error: 'Property not found',
+ data: null,
+ }
+ }
+
+ // Check if user can view this property
+ // Allow if: property is public OR user owns it
+ const isOwner = session?.user && property.owner_id === session.user.id
+ const isPublic = property.is_private === false
+
+ if (!isPublic && !isOwner) {
+ return {
+ success: false,
+ error: 'Property is private',
+ data: null,
+ }
+ }
+
+ // Get the model
+ const { data: model } = await supabase
+ .from('properties_models')
+ .select('*')
+ .eq('property_id', propertyId)
+ .is('deleted_at', null)
+ .order('version', { ascending: false })
+ .limit(1)
+ .maybeSingle()
+
+ return {
+ success: true,
+ data: {
+ property: property as Property,
+ model: model || null,
+ },
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch property',
+ data: null,
+ }
+ }
+}
+
+/**
+ * Increment property view count
+ */
+export async function incrementPropertyViews(propertyId: string): Promise {
+ try {
+ const supabase = await createServerSupabaseClient()
+
+ const { error } = await supabase.rpc('increment_property_views', {
+ property_id: propertyId,
+ })
+
+ if (error) {
+ console.error('Failed to increment views:', error)
+ // Don't fail the request if view increment fails
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to increment views:', error)
+ return { success: true } // Don't fail on view tracking errors
+ }
+}
+
+/**
+ * Update property privacy setting
+ */
+export async function updatePropertyPrivacy(
+ propertyId: string,
+ isPrivate: boolean,
+): Promise {
+ try {
+ const session = await getSession()
+
+ if (!session?.user) {
+ return {
+ success: false,
+ error: 'Not authenticated',
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+
+ // Verify ownership
+ const { data: property } = await supabase
+ .from('properties')
+ .select('owner_id')
+ .eq('id', propertyId)
+ .single()
+
+ if (property?.owner_id !== session.user.id) {
+ return {
+ success: false,
+ error: 'Unauthorized',
+ }
+ }
+
+ // Update privacy
+ const { error } = await supabase
+ .from('properties')
+ .update({ is_private: isPrivate })
+ .eq('id', propertyId)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ return {
+ success: true,
+ message: `Property is now ${isPrivate ? 'private' : 'public'}`,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update property privacy',
+ }
+ }
+}
+
+/**
+ * Update property address
+ */
+export async function updatePropertyAddress(
+ propertyId: string,
+ addressData: {
+ street_number?: string
+ route?: string
+ city?: string
+ state?: string
+ postal_code?: string
+ country?: string
+ },
+): Promise {
+ try {
+ const session = await getSession()
+
+ if (!session?.user) {
+ return {
+ success: false,
+ error: 'Not authenticated',
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+
+ // Verify ownership and get address_id
+ const { data: property } = await supabase
+ .from('properties')
+ .select('owner_id, address_id')
+ .eq('id', propertyId)
+ .single()
+
+ if (!property) {
+ return {
+ success: false,
+ error: 'Property not found',
+ }
+ }
+
+ if (property.owner_id !== session.user.id) {
+ return {
+ success: false,
+ error: 'Unauthorized',
+ }
+ }
+
+ // Update address
+ const { error } = await supabase
+ .from('properties_addresses')
+ .update(addressData)
+ .eq('id', property.address_id)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ return {
+ success: true,
+ message: 'Address updated successfully',
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to update address',
+ }
+ }
+}
+
+/**
+ * Migrate a local property to the cloud
+ * Creates a new property with the local property's data
+ */
+export async function migrateLocalProperty(
+ localProperty: {
+ name: string
+ scene_graph: any
+ },
+): Promise> {
+ try {
+ const session = await getSession()
+
+ if (!session?.user) {
+ return {
+ success: false,
+ error: 'Not authenticated',
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+
+ // Create a default address (user can edit later via settings)
+ const addressId = createId('address')
+ const { error: addressError } = await supabase.from('properties_addresses').insert({
+ id: addressId,
+ country: 'US',
+ })
+
+ if (addressError) {
+ return {
+ success: false,
+ error: addressError.message,
+ }
+ }
+
+ // Create the property
+ const propertyId = createId('property')
+ const { error: propertyError } = await supabase.from('properties').insert({
+ id: propertyId,
+ name: localProperty.name,
+ owner_id: session.user.id,
+ address_id: addressId,
+ is_private: true, // Default to private
+ })
+
+ if (propertyError) {
+ return {
+ success: false,
+ error: propertyError.message,
+ }
+ }
+
+ // Create the model with the scene graph
+ if (localProperty.scene_graph) {
+ const modelId = createId('model')
+ const { error: modelError } = await supabase.from('properties_models').insert({
+ id: modelId,
+ property_id: propertyId,
+ version: 1,
+ scene_graph: localProperty.scene_graph,
+ })
+
+ if (modelError) {
+ return {
+ success: false,
+ error: modelError.message,
+ }
+ }
+ }
+
+ return {
+ success: true,
+ data: { id: propertyId },
+ message: 'Property migrated successfully',
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to migrate property',
+ }
+ }
+}
+
+/**
+ * Delete a property
+ * Only the owner can delete their property
+ */
+export async function deleteProperty(propertyId: string): Promise {
+ try {
+ const session = await getSession()
+
+ if (!session?.user) {
+ return {
+ success: false,
+ error: 'Not authenticated',
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+
+ // Verify ownership
+ const { data: property } = await supabase
+ .from('properties')
+ .select('owner_id')
+ .eq('id', propertyId)
+ .single()
+
+ if (!property) {
+ return {
+ success: false,
+ error: 'Property not found',
+ }
+ }
+
+ if (property.owner_id !== session.user.id) {
+ return {
+ success: false,
+ error: 'Unauthorized',
+ }
+ }
+
+ // Delete the property (cascade will delete related records)
+ const { error } = await supabase.from('properties').delete().eq('id', propertyId)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ return {
+ success: true,
+ message: 'Property deleted successfully',
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to delete property',
+ }
+ }
+}
+
+/**
+ * Check if the current user has liked specific properties
+ * Returns a map of propertyId -> boolean
+ */
+export async function getUserPropertyLikes(
+ propertyIds: string[],
+): Promise>> {
+ try {
+ const session = await getSession()
+
+ if (!session?.user || propertyIds.length === 0) {
+ // Return empty map for unauthenticated users or no properties
+ return {
+ success: true,
+ data: {},
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+
+ const { data: likes, error } = await supabase
+ .from('property_likes')
+ .select('property_id')
+ .eq('user_id', session.user.id)
+ .in('property_id', propertyIds)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ data: {},
+ }
+ }
+
+ // Convert array to map
+ const likeMap: Record = {}
+ propertyIds.forEach((id) => {
+ likeMap[id] = likes?.some((like) => like.property_id === id) || false
+ })
+
+ return {
+ success: true,
+ data: likeMap,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch likes',
+ data: {},
+ }
+ }
+}
+
+/**
+ * Toggle like on a property
+ * Returns the new like state and updated like count
+ */
+export async function togglePropertyLike(
+ propertyId: string,
+): Promise> {
+ try {
+ const session = await getSession()
+
+ if (!session?.user) {
+ return {
+ success: false,
+ error: 'Not authenticated',
+ }
+ }
+
+ const supabase = await createServerSupabaseClient()
+ const userId = session.user.id
+
+ // Check if user has already liked this property
+ const { data: existingLike } = await supabase
+ .from('property_likes')
+ .select('id')
+ .eq('property_id', propertyId)
+ .eq('user_id', userId)
+ .maybeSingle()
+
+ let liked = false
+
+ if (existingLike) {
+ // Unlike - remove the like
+ const { error } = await supabase
+ .from('property_likes')
+ .delete()
+ .eq('id', existingLike.id)
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ liked = false
+ } else {
+ // Like - add a new like
+ const likeId = createId('like')
+ const { error } = await supabase.from('property_likes').insert({
+ id: likeId,
+ property_id: propertyId,
+ user_id: userId,
+ })
+
+ if (error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ liked = true
+ }
+
+ // Get updated like count
+ const { data: likeCount } = await supabase.rpc('get_property_like_count', {
+ property_id: propertyId,
+ })
+
+ // Update the property's like count cache
+ await supabase
+ .from('properties')
+ .update({ likes: likeCount || 0 })
+ .eq('id', propertyId)
+
+ return {
+ success: true,
+ data: {
+ liked,
+ likes: likeCount || 0,
+ },
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to toggle like',
+ }
+ }
+}
diff --git a/apps/editor/features/community/lib/properties/store.ts b/apps/editor/features/community/lib/properties/store.ts
index aef9ab69..405b4d27 100644
--- a/apps/editor/features/community/lib/properties/store.ts
+++ b/apps/editor/features/community/lib/properties/store.ts
@@ -54,17 +54,8 @@ export const usePropertyStore = create((set, get) => ({
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)
- }
- }
- }
+ // Note: Auto-select logic removed - now using URL-based routing
+ // The URL parameter determines which property to load
} else {
set({
error: result.error || 'Failed to fetch active property',
diff --git a/apps/editor/features/community/lib/properties/types.ts b/apps/editor/features/community/lib/properties/types.ts
index 393f583a..2b142c43 100644
--- a/apps/editor/features/community/lib/properties/types.ts
+++ b/apps/editor/features/community/lib/properties/types.ts
@@ -11,6 +11,11 @@ export type Property = {
address_id: string
created_at: string
updated_at: string
+ // Community features
+ is_private: boolean
+ views: number
+ likes: number
+ thumbnail_url: string | null
address: {
id: string
street_number?: string
@@ -40,4 +45,6 @@ export type CreatePropertyParams = {
country?: string
countryLong?: string
rawJson?: Record
+ isPrivate?: boolean
+ sceneGraph?: any
}
diff --git a/apps/editor/lib/navigation.ts b/apps/editor/lib/navigation.ts
new file mode 100644
index 00000000..dbb7b2ad
--- /dev/null
+++ b/apps/editor/lib/navigation.ts
@@ -0,0 +1,15 @@
+/**
+ * Navigation helpers for property-based routing
+ */
+
+export function getEditorUrl(propertyId: string): string {
+ return `/editor/${propertyId}`
+}
+
+export function getViewerUrl(propertyId: string): string {
+ return `/viewer/${propertyId}`
+}
+
+export function getHomeUrl(): string {
+ return '/'
+}
diff --git a/packages/db/src/schema/properties/properties.ts b/packages/db/src/schema/properties/properties.ts
index 5e0dc416..97eafbf0 100644
--- a/packages/db/src/schema/properties/properties.ts
+++ b/packages/db/src/schema/properties/properties.ts
@@ -19,11 +19,19 @@ export const properties = pgTable(
.references(() => users.id, { onDelete: 'set null' }),
detailsJson: t.jsonb('details_json'),
metadata: t.jsonb('metadata'),
+ // Community features
+ isPrivate: t.boolean('is_private').notNull().default(true),
+ views: t.integer('views').notNull().default(0),
+ likes: t.integer('likes').notNull().default(0),
+ thumbnailUrl: t.text('thumbnail_url'),
...timestampsColumns,
}),
(t) => [
index('property_address_idx').on(t.addressId),
index('property_owner_idx').on(t.ownerId),
+ index('property_is_private_idx').on(t.isPrivate),
+ index('property_views_idx').on(t.views),
+ index('property_likes_idx').on(t.likes),
],
).enableRLS()
diff --git a/packages/db/supabase/migrations/20240216000001_add_community_fields.sql b/packages/db/supabase/migrations/20240216000001_add_community_fields.sql
new file mode 100644
index 00000000..a1ef8f7a
--- /dev/null
+++ b/packages/db/supabase/migrations/20240216000001_add_community_fields.sql
@@ -0,0 +1,13 @@
+-- Add community features to properties table
+ALTER TABLE properties ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE properties ADD COLUMN IF NOT EXISTS views INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE properties ADD COLUMN IF NOT EXISTS likes INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE properties ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
+
+-- Create indexes for community queries
+CREATE INDEX IF NOT EXISTS idx_properties_is_private ON properties(is_private) WHERE is_private = false;
+CREATE INDEX IF NOT EXISTS idx_properties_views ON properties(views DESC);
+CREATE INDEX IF NOT EXISTS idx_properties_likes ON properties(likes DESC);
+
+-- Set existing properties to private (user opt-in to share)
+UPDATE properties SET is_private = true WHERE is_private IS NULL;
diff --git a/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql b/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql
new file mode 100644
index 00000000..7ef2c0e7
--- /dev/null
+++ b/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql
@@ -0,0 +1,78 @@
+-- Drop existing RLS policies for properties
+DROP POLICY IF EXISTS "Users can view their own properties" ON properties;
+DROP POLICY IF EXISTS "Users can insert their own properties" ON properties;
+DROP POLICY IF EXISTS "Users can update their own properties" ON properties;
+DROP POLICY IF EXISTS "Users can delete their own properties" ON properties;
+
+-- New RLS policy: Users can view their own properties OR public properties
+CREATE POLICY "Users can view own or public properties"
+ ON properties FOR SELECT
+ USING (
+ owner_id = current_setting('app.user_id', true)::TEXT
+ OR is_private = false
+ OR owner_id IS NULL
+ );
+
+-- Keep other policies the same (insert/update/delete still require ownership)
+CREATE POLICY "Users can insert their own properties"
+ ON properties FOR INSERT
+ WITH CHECK (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
+
+CREATE POLICY "Users can update their own properties"
+ ON properties FOR UPDATE
+ USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
+
+CREATE POLICY "Users can delete their own properties"
+ ON properties FOR DELETE
+ USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
+
+-- Drop existing RLS policies for models
+DROP POLICY IF EXISTS "Users can view models of their own properties" ON properties_models;
+DROP POLICY IF EXISTS "Users can insert models for their own properties" ON properties_models;
+DROP POLICY IF EXISTS "Users can update models of their own properties" ON properties_models;
+DROP POLICY IF EXISTS "Users can delete models of their own properties" ON properties_models;
+
+-- Update models policy: Users can view models of their own properties OR public properties
+CREATE POLICY "Users can view models of own or public properties"
+ ON properties_models FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1 FROM properties
+ WHERE properties.id = properties_models.property_id
+ AND (
+ properties.owner_id = current_setting('app.user_id', true)::TEXT
+ OR properties.is_private = false
+ )
+ )
+ );
+
+-- Keep other model policies the same
+CREATE POLICY "Users can insert models for their own properties"
+ ON properties_models FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM properties
+ WHERE properties.id = properties_models.property_id
+ AND properties.owner_id = current_setting('app.user_id', true)::TEXT
+ )
+ );
+
+CREATE POLICY "Users can update models of their own properties"
+ ON properties_models FOR UPDATE
+ USING (
+ EXISTS (
+ SELECT 1 FROM properties
+ WHERE properties.id = properties_models.property_id
+ AND properties.owner_id = current_setting('app.user_id', true)::TEXT
+ )
+ );
+
+CREATE POLICY "Users can delete models of their own properties"
+ ON properties_models FOR DELETE
+ USING (
+ EXISTS (
+ SELECT 1 FROM properties
+ WHERE properties.id = properties_models.property_id
+ AND properties.owner_id = current_setting('app.user_id', true)::TEXT
+ )
+ );
diff --git a/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql b/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql
new file mode 100644
index 00000000..73fccec2
--- /dev/null
+++ b/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql
@@ -0,0 +1,9 @@
+-- Function to atomically increment view count
+CREATE OR REPLACE FUNCTION increment_property_views(property_id TEXT)
+RETURNS void AS $$
+BEGIN
+ UPDATE properties
+ SET views = views + 1
+ WHERE id = property_id;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
diff --git a/packages/db/supabase/migrations/20240216000004_create_property_likes.sql b/packages/db/supabase/migrations/20240216000004_create_property_likes.sql
new file mode 100644
index 00000000..56fec51e
--- /dev/null
+++ b/packages/db/supabase/migrations/20240216000004_create_property_likes.sql
@@ -0,0 +1,41 @@
+-- Create property_likes table to track user likes
+CREATE TABLE IF NOT EXISTS property_likes (
+ id TEXT PRIMARY KEY,
+ property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ -- Ensure a user can only like a property once
+ UNIQUE(property_id, user_id)
+);
+
+-- Enable RLS
+ALTER TABLE property_likes ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies
+-- Users can view all likes (to see like counts)
+CREATE POLICY "Anyone can view likes"
+ ON property_likes FOR SELECT
+ USING (true);
+
+-- Users can insert their own likes
+CREATE POLICY "Users can create their own likes"
+ ON property_likes FOR INSERT
+ WITH CHECK (user_id = current_setting('app.user_id', true)::TEXT);
+
+-- Users can delete their own likes
+CREATE POLICY "Users can delete their own likes"
+ ON property_likes FOR DELETE
+ USING (user_id = current_setting('app.user_id', true)::TEXT);
+
+-- Create index for efficient querying
+CREATE INDEX IF NOT EXISTS idx_property_likes_property_id ON property_likes(property_id);
+CREATE INDEX IF NOT EXISTS idx_property_likes_user_id ON property_likes(user_id);
+
+-- Function to get like count for a property
+CREATE OR REPLACE FUNCTION get_property_like_count(property_id TEXT)
+RETURNS INTEGER AS $$
+BEGIN
+ RETURN (SELECT COUNT(*)::INTEGER FROM property_likes WHERE property_likes.property_id = $1);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;