hub features
This commit is contained in:
@@ -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 (
|
||||
<div className="flex h-screen w-full max-w-screen">
|
||||
<div className="relative h-full w-full">
|
||||
<Editor propertyId={propertyId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import Editor from '../components/editor'
|
||||
import CommunityHub from '@/features/community/components/community-hub'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex h-screen w-full max-w-screen">
|
||||
<div className="relative h-full w-full">
|
||||
<Editor />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <CommunityHub />
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full h-full">
|
||||
@@ -43,7 +61,7 @@ export default function Editor() {
|
||||
<PascalRadio />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<CloudSaveButton />
|
||||
<CloudSaveButton propertyId={propertyId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="pointer-events-auto">
|
||||
@@ -53,12 +62,25 @@ export function CloudSaveButton() {
|
||||
)
|
||||
}
|
||||
|
||||
// Guest user (no property context or browsing)
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated user
|
||||
return (
|
||||
<div className="pointer-events-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<PropertyDropdown />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<LocalProperty | null>(null)
|
||||
const [publicProperties, setPublicProperties] = useState<Property[]>([])
|
||||
const [userProperties, setUserProperties] = useState<Property[]>([])
|
||||
const [localProperties, setLocalProperties] = useState<LocalProperty[]>([])
|
||||
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 (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Hub</h1>
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
) : (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
||||
{/* User's Properties Section */}
|
||||
{isAuthenticated && (userProperties.length > 0 || localProperties.length > 0) && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">My Properties</h2>
|
||||
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
|
||||
</div>
|
||||
<PropertyGrid
|
||||
properties={[...userProperties, ...localProperties]}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
onViewClick={handleViewProperty}
|
||||
onSaveToCloud={handleSaveLocalToCloud}
|
||||
showOwner={false}
|
||||
canEdit
|
||||
onUpdate={() => {
|
||||
// Reload properties after settings update
|
||||
if (!authLoading) {
|
||||
getUserProperties().then((result) => {
|
||||
if (result.success) {
|
||||
setUserProperties(result.data || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Local Properties Section (Guest Users) */}
|
||||
{!isAuthenticated && localProperties.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">My Local Projects</h2>
|
||||
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
|
||||
</div>
|
||||
<PropertyGrid
|
||||
properties={localProperties}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
showOwner={false}
|
||||
isLocal
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Create First Property CTA */}
|
||||
{!isAuthenticated && localProperties.length === 0 && (
|
||||
<section className="text-center py-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">Get Started</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create your first property to start designing
|
||||
</p>
|
||||
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Public Properties Section */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-6">Community Properties</h2>
|
||||
{publicProperties.length > 0 ? (
|
||||
<PropertyGrid
|
||||
properties={publicProperties}
|
||||
onPropertyClick={handleViewProperty}
|
||||
showOwner
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No public properties yet
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
<NewPropertyDialog
|
||||
open={isNewPropertyDialogOpen}
|
||||
onOpenChange={setIsNewPropertyDialogOpen}
|
||||
onSuccess={handlePropertyCreated}
|
||||
localPropertyData={
|
||||
localPropertyToSave
|
||||
? {
|
||||
id: localPropertyToSave.id,
|
||||
name: localPropertyToSave.name,
|
||||
sceneGraph: localPropertyToSave.scene_graph,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
interface CreatePropertyButtonProps {
|
||||
onCreateProperty: () => void
|
||||
}
|
||||
|
||||
export function CreatePropertyButton({ onCreateProperty }: CreatePropertyButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCreateProperty}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Property</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && !isMigrating && onSkip()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Local Properties to Cloud</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have {localProperties.length} local {localProperties.length === 1 ? 'property' : 'properties'} that {localProperties.length === 1 ? 'hasn\'t' : 'haven\'t'} been saved to the cloud yet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Would you like to save {localProperties.length === 1 ? 'it' : 'them'} to your account?
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{localProperties.map((property) => (
|
||||
<li key={property.id} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="font-medium">{property.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
|
||||
disabled={isMigrating}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMigrate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
disabled={isMigrating}
|
||||
>
|
||||
{isMigrating ? 'Saving...' : 'Save to Cloud'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<AddressData | null>(null)
|
||||
const [isPrivate, setIsPrivate] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Privacy</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Public</span>
|
||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localPropertyData && (
|
||||
<div className="rounded-md border border-blue-500/50 bg-blue-500/10 p-3 text-sm">
|
||||
<p className="font-medium text-blue-700 dark:text-blue-300">
|
||||
Saving local property: {localPropertyData.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Your building data will be preserved
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
|
||||
@@ -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<Property | null>(null)
|
||||
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
|
||||
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
|
||||
|
||||
// Initialize like counts from properties
|
||||
useEffect(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
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 (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{properties.map((property) => (
|
||||
<button
|
||||
key={property.id}
|
||||
onClick={() => onPropertyClick(property.id)}
|
||||
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-muted relative">
|
||||
{!isLocalProperty(property) && property.thumbnail_url ? (
|
||||
<img
|
||||
src={property.thumbnail_url}
|
||||
alt={property.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
{isLocalProperty(property) && (
|
||||
<div className="absolute top-2 right-2">
|
||||
{isAuthenticated && onSaveToCloud ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSaveToCloud(property)
|
||||
}}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
title="Save to cloud"
|
||||
>
|
||||
Save to cloud
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
||||
Local
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && !isLocalProperty(property) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onViewClick && (
|
||||
<button
|
||||
onClick={(e) => handleViewClick(e, property.id)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="View"
|
||||
title="View in viewer mode"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, property)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="Settings"
|
||||
title="Property settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-left line-clamp-2 mb-2">{property.name}</h3>
|
||||
|
||||
{!isLocalProperty(property) && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{property.views}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleLikeClick(e, property.id)}
|
||||
className="flex items-center gap-1 hover:text-red-500 transition-colors"
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${
|
||||
userLikes[property.id]
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<span>{likeCounts[property.id] ?? property.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLocalProperty(property) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(property.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
{settingsProperty && (
|
||||
<PropertySettingsDialog
|
||||
property={settingsProperty}
|
||||
open={!!settingsProperty}
|
||||
onOpenChange={(open) => !open && setSettingsProperty(null)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={() => {
|
||||
setSettingsProperty(null)
|
||||
onUpdate?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Property Settings</DialogTitle>
|
||||
<DialogDescription>Update property address and privacy settings</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Privacy</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Public</span>
|
||||
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Fields */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">Address</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Street Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.street_number}
|
||||
onChange={(e) => setAddress({ ...address, street_number: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Street</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.route}
|
||||
onChange={(e) => setAddress({ ...address, route: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Main St"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.city}
|
||||
onChange={(e) => setAddress({ ...address, city: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">State</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.state}
|
||||
onChange={(e) => setAddress({ ...address, state: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="CA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.postal_code}
|
||||
onChange={(e) => setAddress({ ...address, postal_code: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="94102"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address.country}
|
||||
onChange={(e) => setAddress({ ...address, country: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="US"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Once you delete a property, there is no going back. Please be certain.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
|
||||
disabled={isDeleting || loading}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Property'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
|
||||
disabled={loading || isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
disabled={loading || isDeleting}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<NodeJS.Timeout | undefined>(undefined)
|
||||
const currentPropertyIdRef = useRef<string | null>(null)
|
||||
const lastPropertyIdRef = useRef<string | null>(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])
|
||||
}
|
||||
@@ -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<string, any>
|
||||
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)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
|
||||
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<Acti
|
||||
name: params.name,
|
||||
address_id: address.id,
|
||||
owner_id: session.user.id,
|
||||
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
|
||||
details_json: {
|
||||
coordinates: params.center,
|
||||
createdFrom: 'editor-app',
|
||||
@@ -239,6 +241,22 @@ export async function createProperty(params: CreatePropertyParams): Promise<Acti
|
||||
}
|
||||
}
|
||||
|
||||
// If scene graph is provided, create the model
|
||||
if (params.sceneGraph) {
|
||||
const modelId = createId('model')
|
||||
const { error: modelError } = await supabase.from('properties_models').insert({
|
||||
id: modelId,
|
||||
property_id: propertyId,
|
||||
version: 1,
|
||||
scene_graph: params.sceneGraph,
|
||||
})
|
||||
|
||||
if (modelError) {
|
||||
console.error('Failed to create model:', modelError)
|
||||
// Don't fail the property creation if model creation fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property,
|
||||
@@ -326,3 +344,549 @@ export async function checkPropertyDuplicate(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public properties for community hub
|
||||
*/
|
||||
export async function getPublicProperties(): Promise<ActionResult<Property[]>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult<Record<string, boolean>>> {
|
||||
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<string, boolean> = {}
|
||||
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<ActionResult<{ liked: boolean; likes: number }>> {
|
||||
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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,17 +54,8 @@ export const usePropertyStore = create<PropertyStore>((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',
|
||||
|
||||
@@ -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<string, unknown>
|
||||
isPrivate?: boolean
|
||||
sceneGraph?: any
|
||||
}
|
||||
|
||||
@@ -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 '/'
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user