feat: rename properties to projects

- Rename all DB tables: properties → projects, properties_addresses → projects_addresses, properties_models → projects_models, property_likes → projects_likes
- Add projects_likes as proper Drizzle schema table (was SQL-only before)
- Rename ID prefixes: property_xxx → project_xxx
- Rename auth_sessions column: active_property_id → active_project_id
- Simplify project creation: address is now optional (no longer required to get started)
- Update all actions, stores, types, components, and routes
- Rename route: /editor/[propertyId] → /editor/[projectId]
- Add SQL migration for table/column/index/policy/function renames
- Update RPC functions: increment_project_views, get_project_like_count
- Update storage bucket reference: project-thumbnails
- All types/exports follow existing Drizzle patterns (pgTable, relations, zod schemas)
This commit is contained in:
Anton Pascal
2026-02-17 20:40:52 +00:00
parent 77d64824e5
commit ee30f17129
35 changed files with 1376 additions and 1025 deletions
@@ -0,0 +1,30 @@
'use client'
import Editor from '@/components/editor'
import { useParams } from 'next/navigation'
import { useEffect, useLayoutEffect } from 'react'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { useAuth } from '@/features/community/lib/auth/hooks'
export default function EditorPage() {
const params = useParams()
const projectId = params.projectId as string
const { isAuthenticated } = useAuth()
const setActiveProject = useProjectStore((state) => state.setActiveProject)
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
useLayoutEffect(() => {
// For authenticated users with cloud projects, set the active project from URL
if (isAuthenticated && projectId && !projectId.startsWith('local_')) {
setActiveProject(projectId)
}
}, [projectId, isAuthenticated, setActiveProject])
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor projectId={projectId} />
</div>
</div>
)
}
@@ -1,30 +0,0 @@
'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>
)
}
+9 -9
View File
@@ -8,14 +8,14 @@ import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
import { ThumbnailGenerator } from './thumbnail-generator'
import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions'
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
export default function ViewerPage() {
const params = useParams()
const id = params.id as string
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [propertyId, setPropertyId] = useState<string | null>(null)
const [projectId, setProjectId] = useState<string | null>(null)
const setScene = useScene((state) => state.setScene)
useEffect(() => {
@@ -33,12 +33,12 @@ export default function ViewerPage() {
initSpatialGridSync()
}
} else {
// Load from database (public property)
const result = await getPropertyModelPublic(id)
// Load from database (public project)
const result = await getProjectModelPublic(id)
if (result.success && result.data) {
const { property, model } = result.data
setPropertyId(property.id)
const { project, model } = result.data
setProjectId(project.id)
if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph
@@ -47,9 +47,9 @@ export default function ViewerPage() {
}
// Increment view count
await incrementPropertyViews(id)
await incrementProjectViews(id)
} else {
throw new Error(result.error || 'Property not found')
throw new Error(result.error || 'Project not found')
}
}
@@ -88,7 +88,7 @@ export default function ViewerPage() {
{/* Custom Zone System */}
<ViewerZoneSystem />
{/* Thumbnail Generator */}
<ThumbnailGenerator propertyId={propertyId || undefined} />
<ThumbnailGenerator projectId={projectId || undefined} />
</Viewer>
</div>
)
@@ -4,44 +4,41 @@ import { emitter } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { uploadPropertyThumbnail } from '@/features/community/lib/properties/actions'
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
interface ThumbnailGeneratorProps {
propertyId?: string
projectId?: string
}
export const ThumbnailGenerator = ({ propertyId: propPropertyId }: ThumbnailGeneratorProps) => {
export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const camera = useThree((state) => state.camera)
const isGenerating = useRef(false)
// Use prop propertyId (from URL)
const fallbackPropertyId = propPropertyId
// Use prop projectId (from URL)
const fallbackProjectId = propProjectId
useEffect(() => {
const handleGenerateThumbnail = async (event: { propertyId: string }) => {
const handleGenerateThumbnail = async (event: { projectId: string }) => {
if (isGenerating.current) {
console.log('⏸️ Thumbnail generation already in progress')
return
}
// Prioritize prop propertyId over event propertyId (URL has priority over session)
const propertyId = fallbackPropertyId || event.propertyId
// Prioritize prop projectId over event projectId (URL has priority over session)
const projectId = fallbackProjectId || event.projectId
if (!propertyId) {
console.error('❌ No property ID provided')
if (!projectId) {
console.error('❌ No project ID provided')
return
}
isGenerating.current = true
console.log('📸 Generating thumbnail for property:', propertyId)
console.log('📝 Property ID from URL/prop:', fallbackPropertyId)
console.log('📝 Property ID from event:', event.propertyId)
console.log('✅ Using property ID:', propertyId, fallbackPropertyId ? '(from URL)' : '(from event)')
console.log('📸 Generating thumbnail for project:', projectId)
try {
// Save current renderer state
@@ -70,7 +67,7 @@ export const ThumbnailGenerator = ({ propertyId: propPropertyId }: ThumbnailGene
if (blob) {
// Upload to Supabase Storage
console.log('☁️ Uploading thumbnail to storage...')
const result = await uploadPropertyThumbnail(propertyId, blob)
const result = await uploadProjectThumbnail(projectId, blob)
if (result.success) {
console.log('✅ Thumbnail uploaded successfully!')
@@ -116,7 +113,7 @@ export const ThumbnailGenerator = ({ propertyId: propPropertyId }: ThumbnailGene
return () => {
emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}
}, [gl, scene, camera, fallbackPropertyId])
}, [gl, scene, camera, fallbackProjectId])
return null
}
+13 -13
View File
@@ -4,8 +4,8 @@ import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-a
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 { useProjectScene } from '@/features/community/lib/models/hooks'
import { useLocalProjectScene } 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'
@@ -22,7 +22,7 @@ import { SelectionManager } from './selection-manager'
import { initSFXBus } from '@/lib/sfx-bus'
import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator'
// Load default scene initially (will be replaced when property loads)
// Load default scene initially (will be replaced when project loads)
useScene.getState().loadScene()
initSpatialGridSync()
initSpaceDetectionSync(useScene, useEditor)
@@ -31,23 +31,23 @@ initSpaceDetectionSync(useScene, useEditor)
initSFXBus()
interface EditorProps {
propertyId?: string
projectId?: string
}
export default function Editor({ propertyId }: EditorProps) {
export default function Editor({ projectId }: EditorProps) {
useKeyboard()
const { isAuthenticated } = useAuth()
// Determine which mode to use
const isLocalProperty = propertyId?.startsWith('local_')
const shouldUseCloud = isAuthenticated && !isLocalProperty
const shouldUseLocal = !shouldUseCloud && !!propertyId
const isLocalProject = projectId?.startsWith('local_')
const shouldUseCloud = isAuthenticated && !isLocalProject
const shouldUseLocal = !shouldUseCloud && !!projectId
// 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)
// Cloud hook activates when there's an activeProject in the store
useProjectScene()
// Local hook activates when projectId is provided and starts with 'local_'
useLocalProjectScene(shouldUseLocal ? projectId : undefined)
return (
<div className="w-full h-full">
@@ -74,7 +74,7 @@ export default function Editor({ propertyId }: EditorProps) {
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
<ToolManager />
<CustomCameraControls />
<ThumbnailGenerator propertyId={propertyId} />
<ThumbnailGenerator projectId={projectId} />
</Viewer>
</div>
)
@@ -5,7 +5,7 @@ import { useRef, useState } from "react";
import { Button } from "@/components/ui/primitives/button";
import useEditor from "@/store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog";
import { usePropertyStore } from "@/features/community/lib/properties/store";
import { useProjectStore } from "@/features/community/lib/projects/store";
export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -16,11 +16,11 @@ export function SettingsPanel() {
const resetSelection = useViewer((state) => state.resetSelection);
const exportScene = useViewer((state) => state.exportScene);
const setPhase = useEditor((state) => state.setPhase);
const activeProperty = usePropertyStore((state) => state.activeProperty);
const activeProject = useProjectStore((state) => state.activeProject);
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
const propertyId = activeProperty?.id;
const isLocalProperty = false; // Store only contains cloud properties
const projectId = activeProject?.id;
const isLocalProject = false; // Store only contains cloud projects
const handleExport = async () => {
if (exportScene) {
@@ -71,14 +71,14 @@ export function SettingsPanel() {
};
const handleGenerateThumbnail = () => {
if (!propertyId) {
console.error('❌ No property ID found');
if (!projectId) {
console.error('❌ No project ID found');
return;
}
console.log('🎯 Generate thumbnail clicked for property:', propertyId);
console.log('🎯 Generate thumbnail clicked for project:', projectId);
setIsGeneratingThumbnail(true);
emitter.emit('camera-controls:generate-thumbnail', { propertyId });
console.log('📤 Event emitted with property ID:', propertyId);
emitter.emit('camera-controls:generate-thumbnail', { projectId });
console.log('📤 Event emitted with project ID:', projectId);
// Reset loading state after a delay (thumbnail generation is async)
setTimeout(() => setIsGeneratingThumbnail(false), 3000);
};
@@ -100,8 +100,8 @@ export function SettingsPanel() {
</Button>
</div>
{/* Thumbnail Section (only for cloud properties) */}
{propertyId && !isLocalProperty && (
{/* Thumbnail Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Thumbnail
@@ -4,30 +4,30 @@ 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 { useProjectStore } from '../lib/projects/store'
import { ProfileDropdown } from './profile-dropdown'
import { SignInDialog } from './sign-in-dialog'
interface CloudSaveButtonProps {
propertyId?: string
projectId?: string
}
/**
* CloudSaveButton - Shows authentication state and property management
* CloudSaveButton - Shows authentication state and project management
*
* Guest with local property: Shows "Save to cloud" button
* Guest without property: Shows "Home" button
* Guest with local project: Shows "Save to cloud" button
* Guest without project: Shows "Home" button
* Authenticated: Shows ProfileDropdown
*/
export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
export function CloudSaveButton({ projectId }: CloudSaveButtonProps) {
const { isAuthenticated, isLoading } = useAuth()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const initialize = usePropertyStore(state => state.initialize)
const initialize = useProjectStore(state => state.initialize)
const router = useRouter()
const isLocalProperty = propertyId?.startsWith('local_')
const isLocalProject = projectId?.startsWith('local_')
// Initialize property store when authenticated
// Initialize project store when authenticated
useEffect(() => {
if (isAuthenticated) {
initialize()
@@ -44,8 +44,8 @@ export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
)
}
// Guest user with local property
if (!isAuthenticated && isLocalProperty) {
// Guest user with local project
if (!isAuthenticated && isLocalProject) {
return (
<>
<div className="pointer-events-auto">
@@ -62,7 +62,7 @@ export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
)
}
// Guest user (no property context or browsing)
// Guest user (no project context or browsing)
if (!isAuthenticated) {
return (
<div className="pointer-events-auto">
@@ -4,95 +4,95 @@ import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import type { LocalProperty } from '../lib/local-storage/property-store'
import { createLocalProperty, getLocalProperties } from '../lib/local-storage/property-store'
import { getPublicProperties, getUserProperties } from '../lib/properties/actions'
import type { Property } from '../lib/properties/types'
import { CreatePropertyButton } from './create-property-button'
import { NewPropertyDialog } from './new-property-dialog'
import type { LocalProject } from '../lib/local-storage/project-store'
import { createLocalProject, getLocalProjects } from '../lib/local-storage/project-store'
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
import { CreateProjectButton } from './create-project-button'
import { NewProjectDialog } from './new-project-dialog'
import { ProfileDropdown } from './profile-dropdown'
import { PropertyGrid } from './property-grid'
import { ProjectGrid } from './project-grid'
import { SignInDialog } from './sign-in-dialog'
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 [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
const [localProjectToSave, setLocalProjectToSave] = useState<LocalProject | null>(null)
const [publicProjects, setPublicProjects] = useState<Project[]>([])
const [userProjects, setUserProjects] = useState<Project[]>([])
const [localProjects, setLocalProjects] = useState<LocalProject[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function loadProperties() {
async function loadProjects() {
setLoading(true)
// Load public properties (always)
const publicResult = await getPublicProperties()
// Load public projects (always)
const publicResult = await getPublicProjects()
if (publicResult.success) {
setPublicProperties(publicResult.data || [])
setPublicProjects(publicResult.data || [])
}
// Load user properties if authenticated
// Load user projects if authenticated
if (isAuthenticated) {
const userResult = await getUserProperties()
const userResult = await getUserProjects()
if (userResult.success) {
setUserProperties(userResult.data || [])
setUserProjects(userResult.data || [])
}
}
// Always load local properties
setLocalProperties(getLocalProperties())
// Always load local projects
setLocalProjects(getLocalProjects())
setLoading(false)
}
if (!authLoading) {
loadProperties()
loadProjects()
}
}, [isAuthenticated, authLoading])
const handleCreateProperty = async () => {
const handleCreateProject = async () => {
if (!isAuthenticated) {
// Create local property for guest
const property = createLocalProperty('Untitled Property')
router.push(`/editor/${property.id}`)
// Create local project for guest
const project = createLocalProject('Untitled Project')
router.push(`/editor/${project.id}`)
} else {
// Open property creation dialog for authenticated users
setIsNewPropertyDialogOpen(true)
// Open project creation dialog for authenticated users
setIsNewProjectDialogOpen(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)
const handleProjectCreated = async (projectId: string) => {
// If this was a local project being saved, delete it from localStorage
if (localProjectToSave) {
const { deleteLocalProject } = await import('../lib/local-storage/project-store')
deleteLocalProject(localProjectToSave.id)
setLocalProjects(getLocalProjects())
setLocalProjectToSave(null)
}
// Reload properties and navigate to the new property
const result = await getUserProperties()
// Reload projects and navigate to the new project
const result = await getUserProjects()
if (result.success) {
setUserProperties(result.data || [])
setUserProjects(result.data || [])
}
router.push(`/editor/${propertyId}`)
router.push(`/editor/${projectId}`)
}
const handleSaveLocalToCloud = (localProperty: LocalProperty) => {
setLocalPropertyToSave(localProperty)
setIsNewPropertyDialogOpen(true)
const handleSaveLocalToCloud = (localProject: LocalProject) => {
setLocalProjectToSave(localProject)
setIsNewProjectDialogOpen(true)
}
const handlePropertyClick = (propertyId: string) => {
router.push(`/editor/${propertyId}`)
const handleProjectClick = (projectId: string) => {
router.push(`/editor/${projectId}`)
}
const handleViewProperty = (propertyId: string) => {
router.push(`/viewer/${propertyId}`)
const handleViewProject = (projectId: string) => {
router.push(`/viewer/${projectId}`)
}
if (authLoading || loading) {
@@ -134,26 +134,26 @@ export default function CommunityHub() {
</header>
<main className="container mx-auto px-6 py-8 space-y-12">
{/* User's Properties Section */}
{isAuthenticated && (userProperties.length > 0 || localProperties.length > 0) && (
{/* User's Projects Section */}
{isAuthenticated && (userProjects.length > 0 || localProjects.length > 0) && (
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">My Properties</h2>
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
<h2 className="text-xl font-semibold">My Projects</h2>
<CreateProjectButton onCreateProject={handleCreateProject} />
</div>
<PropertyGrid
properties={[...userProperties, ...localProperties]}
onPropertyClick={handlePropertyClick}
onViewClick={handleViewProperty}
<ProjectGrid
projects={[...userProjects, ...localProjects]}
onProjectClick={handleProjectClick}
onViewClick={handleViewProject}
onSaveToCloud={handleSaveLocalToCloud}
showOwner={false}
canEdit
onUpdate={() => {
// Reload properties after settings update
// Reload projects after settings update
if (!authLoading) {
getUserProperties().then((result) => {
getUserProjects().then((result) => {
if (result.success) {
setUserProperties(result.data || [])
setUserProjects(result.data || [])
}
})
}
@@ -162,59 +162,59 @@ export default function CommunityHub() {
</section>
)}
{/* Local Properties Section (Guest Users) */}
{!isAuthenticated && localProperties.length > 0 && (
{/* Local Projects Section (Guest Users) */}
{!isAuthenticated && localProjects.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} />
<CreateProjectButton onCreateProject={handleCreateProject} />
</div>
<PropertyGrid
properties={localProperties}
onPropertyClick={handlePropertyClick}
<ProjectGrid
projects={localProjects}
onProjectClick={handleProjectClick}
showOwner={false}
isLocal
/>
</section>
)}
{/* Create First Property CTA */}
{!isAuthenticated && localProperties.length === 0 && (
{/* Create First Project CTA */}
{!isAuthenticated && localProjects.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
Create your first project to start designing
</p>
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
<CreateProjectButton onCreateProject={handleCreateProject} />
</section>
)}
{/* Public Properties Section */}
{/* Public Projects Section */}
<section>
<h2 className="text-xl font-semibold mb-6">Community Properties</h2>
{publicProperties.length > 0 ? (
<PropertyGrid
properties={publicProperties}
onPropertyClick={handleViewProperty}
<h2 className="text-xl font-semibold mb-6">Community Projects</h2>
{publicProjects.length > 0 ? (
<ProjectGrid
projects={publicProjects}
onProjectClick={handleViewProject}
showOwner
/>
) : (
<div className="text-center py-12 text-muted-foreground">No public properties yet</div>
<div className="text-center py-12 text-muted-foreground">No public projects yet</div>
)}
</section>
</main>
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
<NewPropertyDialog
open={isNewPropertyDialogOpen}
onOpenChange={setIsNewPropertyDialogOpen}
onSuccess={handlePropertyCreated}
localPropertyData={
localPropertyToSave
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
localProjectData={
localProjectToSave
? {
id: localPropertyToSave.id,
name: localPropertyToSave.name,
sceneGraph: localPropertyToSave.scene_graph,
id: localProjectToSave.id,
name: localProjectToSave.name,
sceneGraph: localProjectToSave.scene_graph,
}
: undefined
}
@@ -2,18 +2,18 @@
import { Plus } from 'lucide-react'
interface CreatePropertyButtonProps {
onCreateProperty: () => void
interface CreateProjectButtonProps {
onCreateProject: () => void
}
export function CreatePropertyButton({ onCreateProperty }: CreatePropertyButtonProps) {
export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProps) {
return (
<button
onClick={onCreateProperty}
onClick={onCreateProject}
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>
<span>Create Project</span>
</button>
)
}
@@ -96,7 +96,7 @@ export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddress
<div className="space-y-2">
<label className="flex items-center gap-2 font-medium text-sm">
<MapPin className="h-4 w-4" />
Property Address
Project Address
</label>
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
<input
@@ -9,21 +9,21 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import type { LocalProperty } from '../lib/local-storage/property-store'
import type { LocalProject } from '../lib/local-storage/project-store'
interface LocalPropertyMigrationDialogProps {
localProperties: LocalProperty[]
interface LocalProjectMigrationDialogProps {
localProjects: LocalProject[]
open: boolean
onMigrate: () => Promise<void>
onSkip: () => void
}
export function LocalPropertyMigrationDialog({
localProperties,
export function LocalProjectMigrationDialog({
localProjects,
open,
onMigrate,
onSkip,
}: LocalPropertyMigrationDialogProps) {
}: LocalProjectMigrationDialogProps) {
const [isMigrating, setIsMigrating] = useState(false)
const handleMigrate = async () => {
@@ -39,21 +39,21 @@ export function LocalPropertyMigrationDialog({
<Dialog open={open} onOpenChange={(open) => !open && !isMigrating && onSkip()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Save Local Properties to Cloud</DialogTitle>
<DialogTitle>Save Local Projects 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.
You have {localProjects.length} local {localProjects.length === 1 ? 'project' : 'projects'} that {localProjects.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?
Would you like to save {localProjects.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">
{localProjects.map((project) => (
<li key={project.id} className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{property.name}</span>
<span className="font-medium">{project.name}</span>
</li>
))}
</ul>
@@ -2,16 +2,16 @@
import { X } from 'lucide-react'
import { useState } from 'react'
import { createProperty } from '../lib/properties/actions'
import { createProject } from '../lib/projects/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 {
interface NewProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: (propertyId: string) => void
localPropertyData?: {
onSuccess?: (projectId: string) => void
localProjectData?: {
id: string
name: string
sceneGraph: any
@@ -30,10 +30,12 @@ interface AddressData {
}
/**
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
* NewProjectDialog - Dialog for creating a new project with optional Google Maps address search
*/
export function NewPropertyDialog({ open, onOpenChange, onSuccess, localPropertyData }: NewPropertyDialogProps) {
export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) {
const [projectName, setProjectName] = useState(localProjectData?.name || '')
const [address, setAddress] = useState<AddressData | null>(null)
const [showAddressSearch, setShowAddressSearch] = useState(false)
const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -47,35 +49,38 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
e.preventDefault()
setError(null)
if (!address) {
setError('Please select an address')
const name = projectName.trim() || (address?.formattedAddress ?? 'Untitled Project')
if (!name) {
setError('Please enter a project name')
return
}
setIsCreating(true)
try {
// Use formatted address as property name (like monorepo)
const result = await createProperty({
name: address.formattedAddress,
center: address.center,
streetNumber: address.streetNumber,
route: address.route,
city: address.city,
state: address.state,
postalCode: address.postalCode,
country: address.country || 'US',
const result = await createProject({
name,
center: address?.center,
streetNumber: address?.streetNumber,
route: address?.route,
city: address?.city,
state: address?.state,
postalCode: address?.postalCode,
country: address?.country || 'US',
isPrivate,
sceneGraph: localPropertyData?.sceneGraph,
sceneGraph: localProjectData?.sceneGraph,
})
if (result.success && result.data) {
onOpenChange(false)
setProjectName('')
setAddress(null)
setShowAddressSearch(false)
setIsPrivate(false)
onSuccess?.(result.data.id)
} else {
setError(result.error || 'Failed to create property')
setError(result.error || 'Failed to create project')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
@@ -87,7 +92,9 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
const handleClose = () => {
if (!isCreating) {
onOpenChange(false)
setProjectName('')
setAddress(null)
setShowAddressSearch(false)
setIsPrivate(false)
setError(null)
}
@@ -100,7 +107,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Add New Property</DialogTitle>
<DialogTitle>Create New Project</DialogTitle>
<button
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
disabled={isCreating}
@@ -112,14 +119,58 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{/* Google Maps Address Search */}
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
{/* Project Name */}
<div>
<label htmlFor="project-name" className="text-sm font-medium">
Project Name
</label>
<input
id="project-name"
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isCreating}
autoFocus
/>
</div>
{/* Show selected address */}
{address && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Selected Address:</p>
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
{/* Optional Address Section */}
{!showAddressSearch ? (
<button
type="button"
onClick={() => setShowAddressSearch(true)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
disabled={isCreating}
>
+ Add an address (optional)
</button>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Address (optional)</label>
<button
type="button"
onClick={() => {
setShowAddressSearch(false)
setAddress(null)
}}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={isCreating}
>
Remove
</button>
</div>
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
{/* Show selected address */}
{address && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Selected Address:</p>
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
</div>
)}
</div>
)}
@@ -128,7 +179,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
<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'}
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
@@ -137,10 +188,10 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
</div>
</div>
{localPropertyData && (
{localProjectData && (
<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}
Saving local project: {localProjectData.name}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Your building data will be preserved
@@ -165,10 +216,10 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess, localProperty
</button>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
disabled={isCreating || !address}
disabled={isCreating}
type="submit"
>
{isCreating ? 'Creating...' : 'Create Property'}
{isCreating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
@@ -0,0 +1,109 @@
'use client'
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useState } from 'react'
import { useProjectStore } from '../lib/projects/store'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
import { NewProjectDialog } from './new-project-dialog'
import { useProjectScene } from '../lib/models/hooks'
/**
* ProjectDropdown - Shows active project and allows switching between projects
*/
export function ProjectDropdown() {
useProjectScene() // Load and auto-save project scenes
// Use project store
const projects = useProjectStore(state => state.projects)
const activeProject = useProjectStore(state => state.activeProject)
const isLoading = useProjectStore(state => state.isLoading)
const setActiveProject = useProjectStore(state => state.setActiveProject)
const fetchProjects = useProjectStore(state => state.fetchProjects)
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
const handleProjectSelect = async (projectId: string) => {
await setActiveProject(projectId)
}
const handleAddNew = () => {
setIsNewProjectDialogOpen(true)
}
const handleProjectCreated = async (projectId: string) => {
// Set the newly created project as active (this will also fetch projects)
await setActiveProject(projectId)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
disabled={isLoading}
type="button"
>
<Home className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{activeProject
? activeProject.name
: projects.length > 0
? 'Select Project'
: 'Add Project'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{/* Project list */}
{projects.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
className={cn(
'cursor-pointer text-sm',
activeProject?.id === project.id && 'cursor-default bg-accent',
)}
key={project.id}
onClick={() =>
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{project.name}</div>
{activeProject?.id === project.id && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</DropdownMenuItem>
))}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
No projects yet
</div>
)}
{/* Add new project option */}
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" />
<span>Add new project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewProjectDialog
open={isNewProjectDialogOpen}
onOpenChange={setIsNewProjectDialogOpen}
onSuccess={handleProjectCreated}
/>
</>
)
}
@@ -2,86 +2,86 @@
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 type { Project } from '../lib/projects/types'
import type { LocalProject } from '../lib/local-storage/project-store'
import { ProjectSettingsDialog } from './project-settings-dialog'
import { getUserProjectLikes, toggleProjectLike } from '../lib/projects/actions'
import { useAuth } from '../lib/auth/hooks'
interface PropertyGridProps {
properties: (Property | LocalProperty)[]
onPropertyClick: (id: string) => void
interface ProjectGridProps {
projects: (Project | LocalProject)[]
onProjectClick: (id: string) => void
onViewClick?: (id: string) => void
onSaveToCloud?: (property: LocalProperty) => void
onSaveToCloud?: (project: LocalProject) => void
showOwner: boolean
isLocal?: boolean
canEdit?: boolean
onUpdate?: () => void
}
function isLocalProperty(prop: Property | LocalProperty): prop is LocalProperty {
function isLocalProject(prop: Project | LocalProject): prop is LocalProject {
return 'is_local' in prop && prop.is_local === true
}
export function PropertyGrid({
properties,
onPropertyClick,
export function ProjectGrid({
projects,
onProjectClick,
onViewClick,
onSaveToCloud,
showOwner,
isLocal = false,
canEdit = false,
onUpdate,
}: PropertyGridProps) {
}: ProjectGridProps) {
const { isAuthenticated } = useAuth()
const [settingsProperty, setSettingsProperty] = useState<Property | null>(null)
const [settingsProject, setSettingsProject] = useState<Project | null>(null)
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
// Initialize like counts from properties
// Initialize like counts from projects
useEffect(() => {
const counts: Record<string, number> = {}
properties.forEach((prop) => {
if (!isLocalProperty(prop)) {
counts[prop.id] = prop.likes
projects.forEach((proj) => {
if (!isLocalProject(proj)) {
counts[proj.id] = proj.likes
}
})
setLikeCounts(counts)
}, [properties])
}, [projects])
// Fetch which properties the user has liked
// Fetch which projects the user has liked
useEffect(() => {
if (!isAuthenticated) {
setUserLikes({})
return
}
const propertyIds = properties
.filter((p) => !isLocalProperty(p))
const projectIds = projects
.filter((p) => !isLocalProject(p))
.map((p) => p.id)
if (propertyIds.length === 0) return
if (projectIds.length === 0) return
getUserPropertyLikes(propertyIds).then((result) => {
getUserProjectLikes(projectIds).then((result) => {
if (result.success && result.data) {
setUserLikes(result.data)
}
})
}, [properties, isAuthenticated])
}, [projects, isAuthenticated])
const handleSettingsClick = (e: React.MouseEvent, property: Property | LocalProperty) => {
const handleSettingsClick = (e: React.MouseEvent, project: Project | LocalProject) => {
e.stopPropagation()
if (!isLocalProperty(property)) {
setSettingsProperty(property)
if (!isLocalProject(project)) {
setSettingsProject(project)
}
}
const handleViewClick = (e: React.MouseEvent, propertyId: string) => {
const handleViewClick = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
onViewClick?.(propertyId)
onViewClick?.(projectId)
}
const handleLikeClick = async (e: React.MouseEvent, propertyId: string) => {
const handleLikeClick = async (e: React.MouseEvent, projectId: string) => {
e.stopPropagation()
if (!isAuthenticated) {
@@ -90,45 +90,45 @@ export function PropertyGrid({
}
// Optimistic update
const wasLiked = userLikes[propertyId] || false
const currentCount = likeCounts[propertyId] || 0
const wasLiked = userLikes[projectId] || false
const currentCount = likeCounts[projectId] || 0
setUserLikes((prev) => ({ ...prev, [propertyId]: !wasLiked }))
setUserLikes((prev) => ({ ...prev, [projectId]: !wasLiked }))
setLikeCounts((prev) => ({
...prev,
[propertyId]: wasLiked ? currentCount - 1 : currentCount + 1
[projectId]: wasLiked ? currentCount - 1 : currentCount + 1
}))
// Call server action
const result = await togglePropertyLike(propertyId)
const result = await toggleProjectLike(projectId)
if (result.success && result.data) {
// Update with actual values from server
const data = result.data
setUserLikes((prev) => ({ ...prev, [propertyId]: data.liked }))
setLikeCounts((prev) => ({ ...prev, [propertyId]: data.likes }))
setUserLikes((prev) => ({ ...prev, [projectId]: data.liked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: data.likes }))
} else {
// Revert on error
setUserLikes((prev) => ({ ...prev, [propertyId]: wasLiked }))
setLikeCounts((prev) => ({ ...prev, [propertyId]: currentCount }))
setUserLikes((prev) => ({ ...prev, [projectId]: wasLiked }))
setLikeCounts((prev) => ({ ...prev, [projectId]: 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) => (
{projects.map((project) => (
<div
key={property.id}
onClick={() => onPropertyClick(property.id)}
key={project.id}
onClick={() => onProjectClick(project.id)}
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left cursor-pointer"
>
{/* Thumbnail */}
<div className="aspect-video bg-muted relative">
{!isLocalProperty(property) && property.thumbnail_url ? (
{!isLocalProject(project) && project.thumbnail_url ? (
<img
src={property.thumbnail_url}
alt={property.name}
src={project.thumbnail_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
@@ -136,13 +136,13 @@ export function PropertyGrid({
No preview
</div>
)}
{isLocalProperty(property) && (
{isLocalProject(project) && (
<div className="absolute top-2 right-2">
{isAuthenticated && onSaveToCloud ? (
<button
onClick={(e) => {
e.stopPropagation()
onSaveToCloud(property)
onSaveToCloud(project)
}}
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
title="Save to cloud"
@@ -156,11 +156,11 @@ export function PropertyGrid({
)}
</div>
)}
{canEdit && !isLocalProperty(property) && (
{canEdit && !isLocalProject(project) && (
<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)}
onClick={(e) => handleViewClick(e, project.id)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="View"
title="View in viewer mode"
@@ -169,10 +169,10 @@ export function PropertyGrid({
</button>
)}
<button
onClick={(e) => handleSettingsClick(e, property)}
onClick={(e) => handleSettingsClick(e, project)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="Settings"
title="Property settings"
title="Project settings"
>
<Settings className="w-4 h-4" />
</button>
@@ -182,34 +182,34 @@ export function PropertyGrid({
{/* Info */}
<div className="p-4">
<h3 className="font-medium text-left line-clamp-2 mb-2">{property.name}</h3>
<h3 className="font-medium text-left line-clamp-2 mb-2">{project.name}</h3>
{!isLocalProperty(property) && (
{!isLocalProject(project) && (
<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>
<span>{project.views}</span>
</div>
<button
onClick={(e) => handleLikeClick(e, property.id)}
onClick={(e) => handleLikeClick(e, project.id)}
className="flex items-center gap-1 hover:text-red-500 transition-colors"
disabled={!isAuthenticated}
>
<Heart
className={`w-4 h-4 ${
userLikes[property.id]
userLikes[project.id]
? 'fill-red-500 text-red-500'
: ''
}`}
/>
<span>{likeCounts[property.id] ?? property.likes}</span>
<span>{likeCounts[project.id] ?? project.likes}</span>
</button>
</div>
)}
{isLocalProperty(property) && (
{isLocalProject(project) && (
<div className="text-sm text-muted-foreground">
{new Date(property.updated_at).toLocaleDateString()}
{new Date(project.updated_at).toLocaleDateString()}
</div>
)}
</div>
@@ -218,14 +218,14 @@ export function PropertyGrid({
</div>
{/* Settings Dialog */}
{settingsProperty && (
<PropertySettingsDialog
property={settingsProperty}
open={!!settingsProperty}
onOpenChange={(open) => !open && setSettingsProperty(null)}
{settingsProject && (
<ProjectSettingsDialog
project={settingsProject}
open={!!settingsProject}
onOpenChange={(open) => !open && setSettingsProject(null)}
onUpdate={onUpdate}
onDelete={() => {
setSettingsProperty(null)
setSettingsProject(null)
onUpdate?.()
}}
/>
@@ -10,42 +10,42 @@ import {
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'
import { updateProjectAddress, updateProjectPrivacy, deleteProject } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
interface PropertySettingsDialogProps {
property: Property
interface ProjectSettingsDialogProps {
project: Project
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function PropertySettingsDialog({
property,
export function ProjectSettingsDialog({
project,
open,
onOpenChange,
onUpdate,
onDelete,
}: PropertySettingsDialogProps) {
}: ProjectSettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isPrivate, setIsPrivate] = useState(property.is_private)
const [isPrivate, setIsPrivate] = useState(project.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',
street_number: project.address?.street_number || '',
route: project.address?.route || '',
city: project.address?.city || '',
state: project.address?.state || '',
postal_code: project.address?.postal_code || '',
country: project.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 (isPrivate !== project.is_private) {
const privacyResult = await updateProjectPrivacy(project.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
@@ -53,21 +53,23 @@ export function PropertySettingsDialog({
}
}
// 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')
// Update address if changed and project has an address
if (project.address) {
const addressChanged =
address.street_number !== (project.address.street_number || '') ||
address.route !== (project.address.route || '') ||
address.city !== (project.address.city || '') ||
address.state !== (project.address.state || '') ||
address.postal_code !== (project.address.postal_code || '') ||
address.country !== (project.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
if (addressChanged) {
const addressResult = await updateProjectAddress(project.id, address)
if (!addressResult.success) {
alert(`Failed to update address: ${addressResult.error}`)
setLoading(false)
return
}
}
}
@@ -81,21 +83,21 @@ export function PropertySettingsDialog({
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProperty(property.id)
const result = await deleteProject(project.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete property: ${result.error}`)
alert(`Failed to delete project: ${result.error}`)
}
} catch (error) {
alert('Failed to delete property')
alert('Failed to delete project')
} finally {
setIsDeleting(false)
}
@@ -105,8 +107,8 @@ export function PropertySettingsDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Property Settings</DialogTitle>
<DialogDescription>Update property address and privacy settings</DialogDescription>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>Update project address and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
@@ -115,7 +117,7 @@ export function PropertySettingsDialog({
<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'}
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
@@ -205,7 +207,7 @@ export function PropertySettingsDialog({
<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.
Once you delete a project, there is no going back. Please be certain.
</p>
<button
type="button"
@@ -213,7 +215,7 @@ export function PropertySettingsDialog({
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'}
{isDeleting ? 'Deleting...' : 'Delete Project'}
</button>
</div>
</div>
@@ -1,109 +0,0 @@
'use client'
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useState } from 'react'
import { usePropertyStore } from '../lib/properties/store'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
import { NewPropertyDialog } from './new-property-dialog'
import { usePropertyScene } from '../lib/models/hooks'
/**
* PropertyDropdown - Shows active property and allows switching between properties
*/
export function PropertyDropdown() {
usePropertyScene() // Load and auto-save property scenes
// Use property store
const properties = usePropertyStore(state => state.properties)
const activeProperty = usePropertyStore(state => state.activeProperty)
const isLoading = usePropertyStore(state => state.isLoading)
const setActiveProperty = usePropertyStore(state => state.setActiveProperty)
const fetchProperties = usePropertyStore(state => state.fetchProperties)
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
const handlePropertySelect = async (propertyId: string) => {
await setActiveProperty(propertyId)
}
const handleAddNew = () => {
setIsNewPropertyDialogOpen(true)
}
const handlePropertyCreated = async (propertyId: string) => {
// Set the newly created property as active (this will also fetch properties)
await setActiveProperty(propertyId)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
disabled={isLoading}
type="button"
>
<Home className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{activeProperty
? activeProperty.name
: properties.length > 0
? 'Select Property'
: 'Add Property'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{/* Property list */}
{properties.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto">
{properties.map((property) => (
<DropdownMenuItem
className={cn(
'cursor-pointer text-sm',
activeProperty?.id === property.id && 'cursor-default bg-accent',
)}
key={property.id}
onClick={() =>
activeProperty?.id === property.id ? null : handlePropertySelect(property.id)
}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{property.name}</div>
{activeProperty?.id === property.id && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</DropdownMenuItem>
))}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
No properties yet
</div>
)}
{/* Add new property option */}
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" />
<span>Add new property</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewPropertyDialog
open={isNewPropertyDialogOpen}
onOpenChange={setIsNewPropertyDialogOpen}
onSuccess={handlePropertyCreated}
/>
</>
)
}
@@ -4,34 +4,34 @@ import { type AnyNodeId, 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'
import { getLocalProject, updateLocalProjectScene } from './project-store'
/**
* Hook for local property scene management (guest users)
* Hook for local project scene management (guest users)
* Loads scene from localStorage and auto-saves changes
*/
export function useLocalPropertyScene(propertyId?: string) {
export function useLocalProjectScene(projectId?: string) {
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const currentPropertyIdRef = useRef<string | null>(null)
const lastPropertyIdRef = useRef<string | null>(null)
const currentProjectIdRef = useRef<string | null>(null)
const lastProjectIdRef = useRef<string | null>(null)
// Load scene when property ID changes
// Load scene when project ID changes
useEffect(() => {
if (!propertyId || !propertyId.startsWith('local_')) {
if (!projectId || !projectId.startsWith('local_')) {
return
}
if (lastPropertyIdRef.current === propertyId) {
if (lastProjectIdRef.current === projectId) {
return
}
lastPropertyIdRef.current = propertyId
currentPropertyIdRef.current = propertyId
lastProjectIdRef.current = projectId
currentProjectIdRef.current = projectId
const property = getLocalProperty(propertyId)
const project = getLocalProject(projectId)
if (property?.scene_graph) {
const { nodes, rootNodeIds } = property.scene_graph
if (project?.scene_graph) {
const { nodes, rootNodeIds } = project.scene_graph
useScene.getState().setScene(nodes, rootNodeIds as AnyNodeId[])
initSpatialGridSync()
} else {
@@ -45,16 +45,16 @@ export function useLocalPropertyScene(propertyId?: string) {
selectedIds: [],
zoneId: null,
})
}, [propertyId])
}, [projectId])
// Auto-save to localStorage with debouncing
useEffect(() => {
if (!propertyId || !propertyId.startsWith('local_')) {
currentPropertyIdRef.current = null
if (!projectId || !projectId.startsWith('local_')) {
currentProjectIdRef.current = null
return
}
currentPropertyIdRef.current = propertyId
currentProjectIdRef.current = projectId
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
const unsubscribe = useScene.subscribe((state) => {
@@ -73,13 +73,13 @@ export function useLocalPropertyScene(propertyId?: string) {
// Debounce save by 1 second (faster than cloud save)
saveTimeoutRef.current = setTimeout(() => {
const currentId = currentPropertyIdRef.current
const currentId = currentProjectIdRef.current
if (!currentId) return
const rootNodeIds = useScene.getState().rootNodeIds
const sceneGraph = { nodes, rootNodeIds }
updateLocalPropertyScene(currentId, sceneGraph)
updateLocalProjectScene(currentId, sceneGraph)
}, 1000)
})
@@ -89,5 +89,5 @@ export function useLocalPropertyScene(propertyId?: string) {
}
unsubscribe()
}
}, [propertyId])
}, [projectId])
}
@@ -0,0 +1,114 @@
/**
* Local storage management for guest users
* Stores projects and scenes in browser localStorage
*/
import { createId } from '../utils/id-generator'
export interface SceneGraph {
nodes: Record<string, any>
rootNodeIds: string[]
}
export interface LocalProject {
id: string // Format: 'local_project_xyz'
name: string
created_at: string
updated_at: string
scene_graph: SceneGraph | null
is_local: true
}
const LOCAL_PROJECTS_KEY = 'pascal_local_projects'
// Keep old key for migration
const LEGACY_LOCAL_PROPERTIES_KEY = 'pascal_local_properties'
function migrateLegacyStorage(): void {
if (typeof window === 'undefined') return
try {
const legacy = localStorage.getItem(LEGACY_LOCAL_PROPERTIES_KEY)
if (legacy && !localStorage.getItem(LOCAL_PROJECTS_KEY)) {
// Migrate old data to new key
const parsed = JSON.parse(legacy)
// Update IDs from local_property_* to local_project_*
const migrated = parsed.map((p: any) => ({
...p,
id: p.id.replace('local_property_', 'local_project_'),
}))
localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(migrated))
localStorage.removeItem(LEGACY_LOCAL_PROPERTIES_KEY)
}
} catch (error) {
console.error('Failed to migrate legacy local properties:', error)
}
}
export function getLocalProjects(): LocalProject[] {
if (typeof window === 'undefined') return []
migrateLegacyStorage()
try {
const stored = localStorage.getItem(LOCAL_PROJECTS_KEY)
return stored ? JSON.parse(stored) : []
} catch (error) {
console.error('Failed to load local projects:', error)
return []
}
}
export function getLocalProject(id: string): LocalProject | null {
const projects = getLocalProjects()
return projects.find((p) => p.id === id) || null
}
export function saveLocalProject(project: LocalProject): void {
const projects = getLocalProjects()
const index = projects.findIndex((p) => p.id === project.id)
if (index >= 0) {
projects[index] = { ...project, updated_at: new Date().toISOString() }
} else {
projects.push(project)
}
localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(projects))
}
export function createLocalProject(name: string): LocalProject {
const project: LocalProject = {
id: createId('local_project'),
name,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
scene_graph: null,
is_local: true,
}
saveLocalProject(project)
return project
}
export function deleteLocalProject(id: string): void {
const projects = getLocalProjects().filter((p) => p.id !== id)
localStorage.setItem(LOCAL_PROJECTS_KEY, JSON.stringify(projects))
}
export function updateLocalProjectScene(id: string, sceneGraph: SceneGraph): void {
const project = getLocalProject(id)
if (project) {
project.scene_graph = sceneGraph
saveLocalProject(project)
}
}
export function migrateLocalProjectsToCloud(userId: string): LocalProject[] {
// Return local projects that need to be migrated
// Actual migration handled by separate function
return getLocalProjects()
}
export function clearLocalProjects(): void {
localStorage.removeItem(LOCAL_PROJECTS_KEY)
}
@@ -1,89 +0,0 @@
/**
* 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)
}
@@ -1,6 +1,6 @@
/**
* Property model actions - Server actions for scene loading/saving
* Manages 3D models (scene graphs) stored in properties_models table
* Project model actions - Server actions for scene loading/saving
* Manages 3D models (scene graphs) stored in projects_models table
*/
'use server'
@@ -9,28 +9,28 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
import type { ActionResult } from '../properties/actions'
import type { ActionResult } from '../projects/actions'
export interface SceneGraph {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
}
export interface PropertyModel {
export interface ProjectModel {
id: string
name: string
version: number
draft: boolean
property_id: string
project_id: string
scene_graph: SceneGraph | null
created_at: string
updated_at: string
}
/**
* Get the latest model for a property (highest version)
* Get the latest model for a project (highest version)
*/
export async function getPropertyModel(propertyId: string): Promise<ActionResult<PropertyModel | null>> {
export async function getProjectModel(projectId: string): Promise<ActionResult<ProjectModel | null>> {
try {
const session = await getSession()
@@ -44,23 +44,23 @@ export async function getPropertyModel(propertyId: string): Promise<ActionResult
const supabase = await createServerSupabaseClient()
// Get the property to verify ownership
const { data: property, error: propertyError } = await supabase
.from('properties')
// Get the project to verify ownership
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, owner_id')
.eq('id', propertyId)
.eq('id', projectId)
.single<{ id: string; owner_id: string }>()
if (propertyError || !property) {
if (projectError || !project) {
return {
success: false,
error: 'Property not found',
error: 'Project not found',
data: null,
}
}
// Verify ownership
if (property.owner_id !== session.user.id) {
if (project.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
@@ -70,17 +70,17 @@ export async function getPropertyModel(propertyId: string): Promise<ActionResult
// Get the latest model (highest version, then most recent)
const { data: model, error: modelError } = await supabase
.from('properties_models')
.from('projects_models')
.select('*')
.eq('property_id', propertyId)
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.single<PropertyModel>()
.single<ProjectModel>()
console.log('[getPropertyModel] Query result:', {
propertyId,
console.log('[getProjectModel] Query result:', {
projectId,
hasModel: !!model,
modelError: modelError?.message,
errorCode: modelError?.code,
@@ -90,14 +90,14 @@ export async function getPropertyModel(propertyId: string): Promise<ActionResult
if (modelError) {
// No model found is not an error - just return null
if (modelError.code === 'PGRST116') {
console.log('[getPropertyModel] No model found (PGRST116), returning null')
console.log('[getProjectModel] No model found (PGRST116), returning null')
return {
success: true,
data: null,
}
}
console.log('[getPropertyModel] Database error:', modelError)
console.log('[getProjectModel] Database error:', modelError)
return {
success: false,
error: modelError.message,
@@ -105,7 +105,7 @@ export async function getPropertyModel(propertyId: string): Promise<ActionResult
}
}
console.log('[getPropertyModel] Model found:', {
console.log('[getProjectModel] Model found:', {
id: model.id,
version: model.version,
hasSceneGraph: !!model.scene_graph,
@@ -113,25 +113,25 @@ export async function getPropertyModel(propertyId: string): Promise<ActionResult
return {
success: true,
data: model as PropertyModel,
data: model as ProjectModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch property model',
error: error instanceof Error ? error.message : 'Failed to fetch project model',
data: null,
}
}
}
/**
* Save or update a property model's scene graph
* Save or update a project model's scene graph
* If a model exists, updates it. Otherwise creates a new one.
*/
export async function savePropertyModel(
propertyId: string,
export async function saveProjectModel(
projectId: string,
sceneGraph: SceneGraph,
): Promise<ActionResult<PropertyModel>> {
): Promise<ActionResult<ProjectModel>> {
try {
const session = await getSession()
@@ -144,22 +144,22 @@ export async function savePropertyModel(
const supabase = await createServerSupabaseClient()
// Get the property to verify ownership
const { data: property, error: propertyError } = await supabase
.from('properties')
// Get the project to verify ownership
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, owner_id, name')
.eq('id', propertyId)
.eq('id', projectId)
.single<{ id: string; owner_id: string; name: string }>()
if (propertyError || !property) {
if (projectError || !project) {
return {
success: false,
error: 'Property not found',
error: 'Project not found',
}
}
// Verify ownership
if (property.owner_id !== session.user.id) {
if (project.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
@@ -168,9 +168,9 @@ export async function savePropertyModel(
// Check if a model already exists
const { data: existingModel } = await supabase
.from('properties_models')
.from('projects_models')
.select('id, version')
.eq('property_id', propertyId)
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.order('created_at', { ascending: false })
@@ -184,11 +184,11 @@ export async function savePropertyModel(
updated_at: new Date().toISOString(),
}
const { data: updatedModel, error: updateError } = (await (supabase
.from('properties_models') as any)
.from('projects_models') as any)
.update(updateData)
.eq('id', existingModel.id)
.select()
.single()) as { data: PropertyModel | null; error: any }
.single()) as { data: ProjectModel | null; error: any }
if (updateError) {
return {
@@ -199,7 +199,7 @@ export async function savePropertyModel(
return {
success: true,
data: updatedModel as PropertyModel,
data: updatedModel as ProjectModel,
message: 'Model updated successfully',
}
} else {
@@ -208,17 +208,17 @@ export async function savePropertyModel(
const insertData = {
id: modelId,
property_id: propertyId,
name: `${property.name} - Editor`,
project_id: projectId,
name: `${project.name} - Editor`,
version: 1,
draft: true,
scene_graph: sceneGraph,
}
const { data: newModel, error: createError } = (await (supabase
.from('properties_models') as any)
.from('projects_models') as any)
.insert(insertData)
.select()
.single()) as { data: PropertyModel | null; error: any }
.single()) as { data: ProjectModel | null; error: any }
if (createError) {
return {
@@ -229,14 +229,14 @@ export async function savePropertyModel(
return {
success: true,
data: newModel as PropertyModel,
data: newModel as ProjectModel,
message: 'Model created successfully',
}
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save property model',
error: error instanceof Error ? error.message : 'Failed to save project model',
}
}
}
@@ -1,5 +1,5 @@
/**
* Hooks for property model (scene) loading and auto-saving
* Hooks for project model (scene) loading and auto-saving
*/
'use client'
@@ -8,48 +8,48 @@ import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import useEditor from '@/store/use-editor'
import { usePropertyStore } from '../properties/store'
import { getPropertyModel, savePropertyModel } from './actions'
import { useProjectStore } from '../projects/store'
import { getProjectModel, saveProjectModel } from './actions'
/**
* Load the scene when a property becomes active
* Load the scene when a project becomes active
* Saves changes automatically with debouncing
*/
export function usePropertyScene() {
// Subscribe to property store
const activeProperty = usePropertyStore((state) => state.activeProperty)
const isLoadingProperty = usePropertyStore((state) => state.isLoading)
export function useProjectScene() {
// Subscribe to project store
const activeProject = useProjectStore((state) => state.activeProject)
const isLoadingProject = useProjectStore((state) => state.isLoading)
const lastPropertyIdRef = useRef<string | null>(null)
const lastProjectIdRef = useRef<string | null>(null)
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isSavingRef = useRef(false)
const currentPropertyIdRef = useRef<string | null>(null)
const currentProjectIdRef = useRef<string | null>(null)
// Extract property ID for dependency tracking
const propertyId = activeProperty?.id ?? null
const propertyName = activeProperty?.name ?? null
// Extract project ID for dependency tracking
const projectId = activeProject?.id ?? null
const projectName = activeProject?.name ?? null
// Load scene when active property changes
// Load scene when active project changes
useEffect(() => {
if (isLoadingProperty) {
if (isLoadingProject) {
return
}
if (!propertyId) {
if (!projectId) {
return
}
// Skip if same property
if (lastPropertyIdRef.current === propertyId) {
// Skip if same project
if (lastProjectIdRef.current === projectId) {
return
}
lastPropertyIdRef.current = propertyId
lastProjectIdRef.current = projectId
// Load the property's scene
// Load the project's scene
async function loadScene() {
try {
const result = await getPropertyModel(propertyId || '')
const result = await getProjectModel(projectId || '')
if (result.success && result.data?.scene_graph) {
// Load the scene graph into the store
@@ -75,16 +75,16 @@ export function usePropertyScene() {
}
loadScene()
}, [propertyId, isLoadingProperty])
}, [projectId, isLoadingProject])
// Auto-save scene changes with debouncing
useEffect(() => {
if (!propertyId) {
currentPropertyIdRef.current = null
if (!projectId) {
currentProjectIdRef.current = null
return
}
currentPropertyIdRef.current = propertyId
currentProjectIdRef.current = projectId
// Subscribe to any scene changes
// Use JSON stringification to detect any node changes, not just count
@@ -113,9 +113,9 @@ export function usePropertyScene() {
// Debounce save by 2 seconds
saveTimeoutRef.current = setTimeout(async () => {
// Get the current property ID at save time (not the captured value)
const currentPropertyId = currentPropertyIdRef.current
if (!currentPropertyId) {
// Get the current project ID at save time (not the captured value)
const currentProjectId = currentProjectIdRef.current
if (!currentProjectId) {
return
}
@@ -125,7 +125,7 @@ export function usePropertyScene() {
isSavingRef.current = true
try {
await savePropertyModel(currentPropertyId, sceneGraph)
await saveProjectModel(currentProjectId, sceneGraph)
} finally {
isSavingRef.current = false
}
@@ -138,5 +138,5 @@ export function usePropertyScene() {
}
unsubscribe()
}
}, [propertyId])
}, [projectId])
}
@@ -1,5 +1,5 @@
/**
* Property actions - Server actions for property management
* Project actions - Server actions for project management
* Uses Better Auth session + Supabase to query the same database as the monorepo
*/
@@ -8,7 +8,7 @@
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
import type { CreatePropertyParams, Property, Database } from './types'
import type { CreateProjectParams, Project, Database } from './types'
export type ActionResult<T = unknown> = {
success: boolean
@@ -18,9 +18,9 @@ export type ActionResult<T = unknown> = {
}
/**
* Fetch all properties for the current user
* Fetch all projects for the current user
*/
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
export async function getUserProjects(): Promise<ActionResult<Project[]>> {
try {
const session = await getSession()
@@ -34,12 +34,12 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
const supabase = await createServerSupabaseClient()
// Query properties table with address relation
// Query projects table with address relation
const { data, error } = await supabase
.from('properties')
.from('projects')
.select(`
*,
address:properties_addresses(*)
address:projects_addresses(*)
`)
.eq('owner_id', session.user.id)
.order('created_at', { ascending: false })
@@ -54,21 +54,21 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
return {
success: true,
data: data as Property[],
data: data as Project[],
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch properties',
error: error instanceof Error ? error.message : 'Failed to fetch projects',
data: [],
}
}
}
/**
* Get a specific property by ID for the current user
* Get a specific project by ID for the current user
*/
export async function getPropertyById(propertyId: string): Promise<ActionResult<Property | null>> {
export async function getProjectById(projectId: string): Promise<ActionResult<Project | null>> {
try {
const session = await getSession()
@@ -79,30 +79,30 @@ export async function getPropertyById(propertyId: string): Promise<ActionResult<
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase
.from('properties')
.select(`*, address:properties_addresses(*)`)
.eq('id', propertyId)
.from('projects')
.select(`*, address:projects_addresses(*)`)
.eq('id', projectId)
.eq('owner_id', session.user.id)
.single<Property>()
.single<Project>()
if (error) {
return { success: false, error: error.message, data: null }
}
return { success: true, data: data as Property }
return { success: true, data: data as Project }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch property',
error: error instanceof Error ? error.message : 'Failed to fetch project',
data: null,
}
}
}
/**
* Get the active property for the current session
* Get the active project for the current session
*/
export async function getActiveProperty(): Promise<ActionResult<Property | null>> {
export async function getActiveProject(): Promise<ActionResult<Project | null>> {
try {
const session = await getSession()
@@ -116,29 +116,29 @@ export async function getActiveProperty(): Promise<ActionResult<Property | null>
const supabase = await createServerSupabaseClient()
// Get session's active_property_id from sessions table
// Get session's active_project_id from sessions table
const { data: sessionData, error: sessionError } = await supabase
.from('auth_sessions')
.select('active_property_id')
.select('active_project_id')
.eq('user_id', session.user.id)
.single<{ active_property_id: string | null }>()
.single<{ active_project_id: string | null }>()
if (sessionError || !sessionData?.active_property_id) {
if (sessionError || !sessionData?.active_project_id) {
return {
success: true,
data: null,
}
}
// Get the property with address
// Get the project with address
const { data, error } = await supabase
.from('properties')
.from('projects')
.select(`
*,
address:properties_addresses(*)
address:projects_addresses(*)
`)
.eq('id', sessionData.active_property_id)
.single<Property>()
.eq('id', sessionData.active_project_id)
.single<Project>()
if (error) {
return {
@@ -150,21 +150,21 @@ export async function getActiveProperty(): Promise<ActionResult<Property | null>
return {
success: true,
data: data as Property,
data: data as Project,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch active property',
error: error instanceof Error ? error.message : 'Failed to fetch active project',
data: null,
}
}
}
/**
* Set the active property for the current session
* Set the active project for the current session
*/
export async function setActiveProperty(propertyId: string | null): Promise<ActionResult> {
export async function setActiveProject(projectId: string | null): Promise<ActionResult> {
try {
const session = await getSession()
@@ -177,10 +177,10 @@ export async function setActiveProperty(propertyId: string | null): Promise<Acti
const supabase = await createServerSupabaseClient()
// Update session's active_property_id
// Update session's active_project_id
const { error } = await (supabase
.from('auth_sessions') as any)
.update({ active_property_id: propertyId })
.update({ active_project_id: projectId })
.eq('user_id', session.user.id)
if (error) {
@@ -192,20 +192,20 @@ export async function setActiveProperty(propertyId: string | null): Promise<Acti
return {
success: true,
message: propertyId ? 'Active property updated' : 'Active property cleared',
message: projectId ? 'Active project updated' : 'Active project cleared',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set active property',
error: error instanceof Error ? error.message : 'Failed to set active project',
}
}
}
/**
* Create a new property
* Create a new project
*/
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
export async function createProject(params: CreateProjectParams): Promise<ActionResult<Project>> {
try {
const session = await getSession()
@@ -218,55 +218,62 @@ export async function createProperty(params: CreatePropertyParams): Promise<Acti
const supabase = await createServerSupabaseClient()
// Generate IDs for address and property
const addressId = createId('address')
const propertyId = createId('property')
const projectId = createId('project')
let addressId: string | null = null
// First, create the address
const addressData = {
id: addressId,
street_number: params.streetNumber,
route: params.route,
city: params.city || '',
state: params.state || '',
postal_code: params.postalCode || '',
country: params.country || 'US',
latitude: params.center[1].toString(),
longitude: params.center[0].toString(),
}
const { data: address, error: addressError } = (await (supabase
.from('properties_addresses') as any)
.insert(addressData)
.select()
.single()) as { data: Property['address'] | null; error: any }
// Only create address if address data is provided
const hasAddressData = params.center || params.streetNumber || params.route || params.city || params.state || params.postalCode
if (hasAddressData) {
addressId = createId('address')
const addressData = {
id: addressId,
street_number: params.streetNumber,
route: params.route,
city: params.city || '',
state: params.state || '',
postal_code: params.postalCode || '',
country: params.country || 'US',
latitude: params.center ? params.center[1].toString() : undefined,
longitude: params.center ? params.center[0].toString() : undefined,
}
const { error: addressError } = (await (supabase
.from('projects_addresses') as any)
.insert(addressData)
.select()
.single()) as { data: any; error: any }
if (addressError || !address) {
return {
success: false,
error: addressError?.message || 'Failed to create address',
if (addressError) {
return {
success: false,
error: addressError?.message || 'Failed to create address',
}
}
}
// Create the property
const propertyData = {
id: propertyId,
// Create the project
const projectData = {
id: projectId,
name: params.name,
address_id: address.id,
address_id: addressId,
owner_id: session.user.id,
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
details_json: {
coordinates: params.center,
createdFrom: 'editor-app',
},
details_json: params.center
? {
coordinates: params.center,
createdFrom: 'editor-app',
}
: {
createdFrom: 'editor-app',
},
}
const { data, error } = (await (supabase
.from('properties') as any)
.insert(propertyData)
.from('projects') as any)
.insert(projectData)
.select(`
*,
address:properties_addresses(*)
address:projects_addresses(*)
`)
.single()) as { data: Property | null; error: any }
.single()) as { data: Project | null; error: any }
if (error) {
return {
@@ -278,36 +285,36 @@ 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({
const { error: modelError } = await supabase.from('projects_models').insert({
id: modelId,
property_id: propertyId,
project_id: projectId,
version: 1,
scene_graph: params.sceneGraph,
} as any)
if (modelError) {
console.error('Failed to create model:', modelError)
// Don't fail the property creation if model creation fails
// Don't fail the project creation if model creation fails
}
}
return {
success: true,
data: data as Property,
message: 'Property created successfully',
data: data as Project,
message: 'Project created successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create property',
error: error instanceof Error ? error.message : 'Failed to create project',
}
}
}
/**
* Check if a property with the given address already exists
* Check if a project with the given address already exists
*/
export async function checkPropertyDuplicate(params: {
export async function checkProjectDuplicate(params: {
streetNumber?: string
route?: string
city?: string
@@ -316,8 +323,8 @@ export async function checkPropertyDuplicate(params: {
}): Promise<
ActionResult<{
isDuplicate: boolean
isUserProperty?: boolean
existingProperty?: Property
isUserProject?: boolean
existingProject?: Project
}>
> {
try {
@@ -332,12 +339,12 @@ export async function checkPropertyDuplicate(params: {
const supabase = await createServerSupabaseClient()
// Query for existing property with matching address
// Query for existing project with matching address
const { data, error } = await supabase
.from('properties')
.from('projects')
.select(`
*,
address:properties_addresses!inner(*)
address:projects_addresses!inner(*)
`)
.eq('address.street_number', params.streetNumber || '')
.eq('address.route', params.route || '')
@@ -354,13 +361,13 @@ export async function checkPropertyDuplicate(params: {
}
if (data && data.length > 0) {
const existingProperty = data[0] as unknown as Property
const existingProject = data[0] as unknown as Project
return {
success: true,
data: {
isDuplicate: true,
isUserProperty: existingProperty.owner_id === session.user.id,
existingProperty,
isUserProject: existingProject.owner_id === session.user.id,
existingProject,
},
}
}
@@ -380,17 +387,17 @@ export async function checkPropertyDuplicate(params: {
}
/**
* Fetch public properties for community hub
* Fetch public projects for community hub
*/
export async function getPublicProperties(): Promise<ActionResult<Property[]>> {
export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
try {
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase
.from('properties')
.from('projects')
.select(`
*,
address:properties_addresses(*)
address:projects_addresses(*)
`)
.eq('is_private', false)
.order('views', { ascending: false })
@@ -406,65 +413,65 @@ export async function getPublicProperties(): Promise<ActionResult<Property[]>> {
return {
success: true,
data: data as Property[],
data: data as Project[],
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch public properties',
error: error instanceof Error ? error.message : 'Failed to fetch public projects',
data: [],
}
}
}
/**
* Get a property model for viewing
* Allows viewing if: property is public OR user owns the property
* Get a project model for viewing
* Allows viewing if: project is public OR user owns the project
*/
export async function getPropertyModelPublic(propertyId: string): Promise<
ActionResult<{ property: Property; model: any | null }>
export async function getProjectModelPublic(projectId: string): Promise<
ActionResult<{ project: Project; 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')
// Get the project (without privacy filter first)
const { data: project, error: projectError } = await supabase
.from('projects')
.select(`
*,
address:properties_addresses(*)
address:projects_addresses(*)
`)
.eq('id', propertyId)
.eq('id', projectId)
.single()
if (propertyError || !property) {
if (projectError || !project) {
return {
success: false,
error: 'Property not found',
error: 'Project not found',
data: undefined,
}
}
// Check if user can view this property
// Allow if: property is public OR user owns it
const propertyData = property as any
const isOwner = session?.user && propertyData.owner_id === session.user.id
const isPublic = propertyData.is_private === false
// Check if user can view this project
// Allow if: project is public OR user owns it
const projectData = project as any
const isOwner = session?.user && projectData.owner_id === session.user.id
const isPublic = projectData.is_private === false
if (!isPublic && !isOwner) {
return {
success: false,
error: 'Property is private',
error: 'Project is private',
data: undefined,
}
}
// Get the model
const { data: model } = await supabase
.from('properties_models')
.from('projects_models')
.select('*')
.eq('property_id', propertyId)
.eq('project_id', projectId)
.is('deleted_at', null)
.order('version', { ascending: false })
.limit(1)
@@ -473,28 +480,28 @@ export async function getPropertyModelPublic(propertyId: string): Promise<
return {
success: true,
data: {
property: propertyData as Property,
project: projectData as Project,
model: model || null,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch property',
error: error instanceof Error ? error.message : 'Failed to fetch project',
data: undefined,
}
}
}
/**
* Increment property view count
* Increment project view count
*/
export async function incrementPropertyViews(propertyId: string): Promise<ActionResult> {
export async function incrementProjectViews(projectId: string): Promise<ActionResult> {
try {
const supabase = await createServerSupabaseClient()
const { error } = await supabase.rpc('increment_property_views', {
property_id: propertyId,
const { error } = await supabase.rpc('increment_project_views', {
project_id: projectId,
} as any)
if (error) {
@@ -510,10 +517,10 @@ export async function incrementPropertyViews(propertyId: string): Promise<Action
}
/**
* Update property privacy setting
* Update project privacy setting
*/
export async function updatePropertyPrivacy(
propertyId: string,
export async function updateProjectPrivacy(
projectId: string,
isPrivate: boolean,
): Promise<ActionResult> {
try {
@@ -529,13 +536,13 @@ export async function updatePropertyPrivacy(
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: property } = await supabase
.from('properties')
const { data: project } = await supabase
.from('projects')
.select('owner_id')
.eq('id', propertyId)
.eq('id', projectId)
.single()
if ((property as any)?.owner_id !== session.user.id) {
if ((project as any)?.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
@@ -544,9 +551,9 @@ export async function updatePropertyPrivacy(
// Update privacy
const { error } = await (supabase
.from('properties') as any)
.from('projects') as any)
.update({ is_private: isPrivate })
.eq('id', propertyId)
.eq('id', projectId)
if (error) {
return {
@@ -557,21 +564,21 @@ export async function updatePropertyPrivacy(
return {
success: true,
message: `Property is now ${isPrivate ? 'private' : 'public'}`,
message: `Project is now ${isPrivate ? 'private' : 'public'}`,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update property privacy',
error: error instanceof Error ? error.message : 'Failed to update project privacy',
}
}
}
/**
* Update property address
* Update project address
*/
export async function updatePropertyAddress(
propertyId: string,
export async function updateProjectAddress(
projectId: string,
addressData: {
street_number?: string
route?: string
@@ -594,20 +601,20 @@ export async function updatePropertyAddress(
const supabase = await createServerSupabaseClient()
// Verify ownership and get address_id
const { data: property } = await supabase
.from('properties')
const { data: project } = await supabase
.from('projects')
.select('owner_id, address_id')
.eq('id', propertyId)
.eq('id', projectId)
.single()
if (!property) {
if (!project) {
return {
success: false,
error: 'Property not found',
error: 'Project not found',
}
}
if ((property as any).owner_id !== session.user.id) {
if ((project as any).owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
@@ -616,9 +623,9 @@ export async function updatePropertyAddress(
// Update address
const { error } = await (supabase
.from('properties_addresses') as any)
.from('projects_addresses') as any)
.update(addressData)
.eq('id', (property as any).address_id)
.eq('id', (project as any).address_id)
if (error) {
return {
@@ -640,11 +647,11 @@ export async function updatePropertyAddress(
}
/**
* Migrate a local property to the cloud
* Creates a new property with the local property's data
* Migrate a local project to the cloud
* Creates a new project with the local project's data
*/
export async function migrateLocalProperty(
localProperty: {
export async function migrateLocalProject(
localProject: {
name: string
scene_graph: any
},
@@ -661,45 +668,31 @@ export async function migrateLocalProperty(
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') as any).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') as any).insert({
id: propertyId,
name: localProperty.name,
// Create the project without an address (user can add one later via settings)
const projectId = createId('project')
const { error: projectError } = await (supabase.from('projects') as any).insert({
id: projectId,
name: localProject.name,
owner_id: session.user.id,
address_id: addressId,
address_id: null,
is_private: true, // Default to private
})
if (propertyError) {
if (projectError) {
return {
success: false,
error: propertyError.message,
error: projectError.message,
}
}
// Create the model with the scene graph
if (localProperty.scene_graph) {
if (localProject.scene_graph) {
const modelId = createId('model')
const { error: modelError } = await (supabase.from('properties_models') as any).insert({
const { error: modelError } = await (supabase.from('projects_models') as any).insert({
id: modelId,
property_id: propertyId,
project_id: projectId,
version: 1,
scene_graph: localProperty.scene_graph,
scene_graph: localProject.scene_graph,
})
if (modelError) {
@@ -712,22 +705,22 @@ export async function migrateLocalProperty(
return {
success: true,
data: { id: propertyId },
message: 'Property migrated successfully',
data: { id: projectId },
message: 'Project migrated successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to migrate property',
error: error instanceof Error ? error.message : 'Failed to migrate project',
}
}
}
/**
* Delete a property
* Only the owner can delete their property
* Delete a project
* Only the owner can delete their project
*/
export async function deleteProperty(propertyId: string): Promise<ActionResult> {
export async function deleteProject(projectId: string): Promise<ActionResult> {
try {
const session = await getSession()
@@ -741,28 +734,28 @@ export async function deleteProperty(propertyId: string): Promise<ActionResult>
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: property } = await supabase
.from('properties')
const { data: project } = await supabase
.from('projects')
.select('owner_id')
.eq('id', propertyId)
.eq('id', projectId)
.single()
if (!property) {
if (!project) {
return {
success: false,
error: 'Property not found',
error: 'Project not found',
}
}
if ((property as any).owner_id !== session.user.id) {
if ((project as any).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)
// Delete the project (cascade will delete related records)
const { error } = await supabase.from('projects').delete().eq('id', projectId)
if (error) {
return {
@@ -773,28 +766,28 @@ export async function deleteProperty(propertyId: string): Promise<ActionResult>
return {
success: true,
message: 'Property deleted successfully',
message: 'Project deleted successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete property',
error: error instanceof Error ? error.message : 'Failed to delete project',
}
}
}
/**
* Check if the current user has liked specific properties
* Returns a map of propertyId -> boolean
* Check if the current user has liked specific projects
* Returns a map of projectId -> boolean
*/
export async function getUserPropertyLikes(
propertyIds: string[],
export async function getUserProjectLikes(
projectIds: 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
if (!session?.user || projectIds.length === 0) {
// Return empty map for unauthenticated users or no projects
return {
success: true,
data: {},
@@ -804,10 +797,10 @@ export async function getUserPropertyLikes(
const supabase = await createServerSupabaseClient()
const { data: likes, error } = await supabase
.from('property_likes')
.select('property_id')
.from('projects_likes')
.select('project_id')
.eq('user_id', session.user.id)
.in('property_id', propertyIds)
.in('project_id', projectIds)
if (error) {
return {
@@ -819,8 +812,8 @@ export async function getUserPropertyLikes(
// Convert array to map
const likeMap: Record<string, boolean> = {}
propertyIds.forEach((id) => {
likeMap[id] = likes?.some((like) => (like as any).property_id === id) || false
projectIds.forEach((id) => {
likeMap[id] = likes?.some((like) => (like as any).project_id === id) || false
})
return {
@@ -837,11 +830,11 @@ export async function getUserPropertyLikes(
}
/**
* Toggle like on a property
* Toggle like on a project
* Returns the new like state and updated like count
*/
export async function togglePropertyLike(
propertyId: string,
export async function toggleProjectLike(
projectId: string,
): Promise<ActionResult<{ liked: boolean; likes: number }>> {
try {
const session = await getSession()
@@ -856,11 +849,11 @@ export async function togglePropertyLike(
const supabase = await createServerSupabaseClient()
const userId = session.user.id
// Check if user has already liked this property
// Check if user has already liked this project
const { data: existingLike } = await supabase
.from('property_likes')
.from('projects_likes')
.select('id')
.eq('property_id', propertyId)
.eq('project_id', projectId)
.eq('user_id', userId)
.maybeSingle()
@@ -869,7 +862,7 @@ export async function togglePropertyLike(
if (existingLike) {
// Unlike - remove the like
const { error } = await (supabase
.from('property_likes') as any)
.from('projects_likes') as any)
.delete()
.eq('id', (existingLike as any).id)
@@ -884,9 +877,9 @@ export async function togglePropertyLike(
} else {
// Like - add a new like
const likeId = createId('like')
const { error } = await (supabase.from('property_likes') as any).insert({
const { error } = await (supabase.from('projects_likes') as any).insert({
id: likeId,
property_id: propertyId,
project_id: projectId,
user_id: userId,
})
@@ -901,15 +894,15 @@ export async function togglePropertyLike(
}
// Get updated like count
const { data: likeCount } = await supabase.rpc('get_property_like_count', {
property_id: propertyId,
const { data: likeCount } = await supabase.rpc('get_project_like_count', {
project_id: projectId,
} as any)
// Update the property's like count cache
// Update the project's like count cache
await (supabase
.from('properties') as any)
.from('projects') as any)
.update({ likes: likeCount || 0 })
.eq('id', propertyId)
.eq('id', projectId)
return {
success: true,
@@ -927,10 +920,10 @@ export async function togglePropertyLike(
}
/**
* Upload a thumbnail image to Supabase Storage and update the property
* Upload a thumbnail image to Supabase Storage and update the project
*/
export async function uploadPropertyThumbnail(
propertyId: string,
export async function uploadProjectThumbnail(
projectId: string,
blob: Blob
): Promise<{ success: true; data: { thumbnail_url: string } } | { success: false; error: string }> {
try {
@@ -947,28 +940,28 @@ export async function uploadPropertyThumbnail(
const supabase = await createServerSupabaseClient()
// Verify the user owns this property
const { data: property, error: propertyError } = await supabase
.from('properties')
// Verify the user owns this project
const { data: project, error: projectError } = await supabase
.from('projects')
.select('owner_id')
.eq('id', propertyId)
.eq('id', projectId)
.single()
if (propertyError || !property) {
return { success: false, error: 'Property not found' }
if (projectError || !project) {
return { success: false, error: 'Project not found' }
}
if ((property as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized to update this property' }
if ((project as any).owner_id !== session.user.id) {
return { success: false, error: 'Not authorized to update this project' }
}
// Generate a unique filename
const timestamp = Date.now()
const filename = `${propertyId}/${timestamp}.png`
const filename = `${projectId}/${timestamp}.png`
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('property-thumbnails')
.from('project-thumbnails')
.upload(filename, blob, {
contentType: 'image/png',
upsert: false,
@@ -980,19 +973,19 @@ export async function uploadPropertyThumbnail(
// Get the public URL
const { data: urlData } = supabase.storage
.from('property-thumbnails')
.from('project-thumbnails')
.getPublicUrl(uploadData.path)
const thumbnailUrl = urlData.publicUrl
// Update the property with the new thumbnail URL
// Update the project with the new thumbnail URL
const { error: updateError } = await (supabase
.from('properties') as any)
.from('projects') as any)
.update({ thumbnail_url: thumbnailUrl })
.eq('id', propertyId)
.eq('id', projectId)
if (updateError) {
return { success: false, error: `Failed to update property: ${updateError.message}` }
return { success: false, error: `Failed to update project: ${updateError.message}` }
}
return {
@@ -0,0 +1,89 @@
/**
* Project store - Zustand store for project state management
*/
import { create } from 'zustand'
import type { Project } from './types'
import {
getActiveProject,
getUserProjects,
getProjectById,
} from './actions'
interface ProjectStore {
// State
activeProject: Project | null
projects: Project[]
isLoading: boolean
error: string | null
// Actions
fetchProjects: () => Promise<void>
fetchActiveProject: () => Promise<void>
setActiveProject: (projectId: string) => Promise<void>
initialize: () => Promise<void>
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
// Initial state
activeProject: null,
projects: [],
isLoading: true,
error: null,
// Fetch all projects
fetchProjects: async () => {
const result = await getUserProjects()
if (result.success) {
set({ projects: result.data || [], error: null })
} else {
set({ error: result.error || 'Failed to fetch projects', projects: [] })
}
},
// Fetch the active project from database
fetchActiveProject: async () => {
set({ isLoading: true })
const result = await getActiveProject()
if (result.success) {
set({
activeProject: result.data || null,
isLoading: false,
error: null
})
// Note: Auto-select logic removed - now using URL-based routing
// The URL parameter determines which project to load
} else {
set({
error: result.error || 'Failed to fetch active project',
activeProject: null,
isLoading: false
})
}
},
// Set active project by fetching it directly by ID (URL-based, no session update)
setActiveProject: async (projectId: string) => {
set({ isLoading: true })
const result = await getProjectById(projectId)
if (result.success && result.data) {
set({ activeProject: result.data, isLoading: false, error: null })
} else {
set({ isLoading: false, error: result.error || 'Project not found' })
}
},
// Initialize - fetch both projects and active project
initialize: async () => {
set({ isLoading: true })
await Promise.all([
get().fetchProjects(),
get().fetchActiveProject(),
])
},
}))
@@ -1,15 +1,15 @@
/**
* Property-related type definitions
* Project-related type definitions
* Isolated from monorepo database schema
*/
// Database table row types
export type DbProperty = {
export type DbProject = {
id: string
name: string
owner_id: string
organization_id: string | null
address_id: string
address_id: string | null
created_at: string
updated_at: string
is_private: boolean
@@ -18,7 +18,7 @@ export type DbProperty = {
thumbnail_url: string | null
}
export type DbPropertyAddress = {
export type DbProjectAddress = {
id: string
street_number?: string
route?: string
@@ -32,9 +32,9 @@ export type DbPropertyAddress = {
updated_at: string
}
export type DbPropertyModel = {
export type DbProjectModel = {
id: string
property_id: string
project_id: string
version: number
scene_graph: any
created_at: string
@@ -42,9 +42,9 @@ export type DbPropertyModel = {
deleted_at: string | null
}
export type DbPropertyLike = {
export type DbProjectLike = {
id: string
property_id: string
project_id: string
user_id: string
created_at: string
}
@@ -53,46 +53,46 @@ export type DbPropertyLike = {
export type Database = {
public: {
Tables: {
properties: {
Row: DbProperty
Insert: Omit<DbProperty, 'created_at' | 'updated_at' | 'views' | 'likes'>
Update: Partial<Omit<DbProperty, 'id' | 'created_at' | 'updated_at'>>
projects: {
Row: DbProject
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes'>
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
}
properties_addresses: {
Row: DbPropertyAddress
Insert: Omit<DbPropertyAddress, 'created_at' | 'updated_at'>
Update: Partial<Omit<DbPropertyAddress, 'id' | 'created_at' | 'updated_at'>>
projects_addresses: {
Row: DbProjectAddress
Insert: Omit<DbProjectAddress, 'created_at' | 'updated_at'>
Update: Partial<Omit<DbProjectAddress, 'id' | 'created_at' | 'updated_at'>>
}
properties_models: {
Row: DbPropertyModel
Insert: Omit<DbPropertyModel, 'created_at' | 'updated_at' | 'deleted_at'>
Update: Partial<Omit<DbPropertyModel, 'id' | 'created_at' | 'updated_at'>>
projects_models: {
Row: DbProjectModel
Insert: Omit<DbProjectModel, 'created_at' | 'updated_at' | 'deleted_at'>
Update: Partial<Omit<DbProjectModel, 'id' | 'created_at' | 'updated_at'>>
}
property_likes: {
Row: DbPropertyLike
Insert: Omit<DbPropertyLike, 'created_at'>
Update: Partial<Omit<DbPropertyLike, 'id' | 'created_at'>>
projects_likes: {
Row: DbProjectLike
Insert: Omit<DbProjectLike, 'created_at'>
Update: Partial<Omit<DbProjectLike, 'id' | 'created_at'>>
}
}
Functions: {
increment_property_views: {
Args: { property_id: string }
increment_project_views: {
Args: { project_id: string }
Returns: undefined
}
get_property_like_count: {
Args: { property_id: string }
get_project_like_count: {
Args: { project_id: string }
Returns: number
}
}
}
}
export type Property = {
export type Project = {
id: string
name: string
owner_id: string
organization_id: string | null
address_id: string
address_id: string | null
created_at: string
updated_at: string
// Community features
@@ -110,12 +110,12 @@ export type Property = {
country?: string
latitude?: string
longitude?: string
}
} | null
}
export type CreatePropertyParams = {
export type CreateProjectParams = {
name: string
center: [number, number]
center?: [number, number]
streetNumber?: string
route?: string
routeShort?: string
@@ -1,89 +0,0 @@
/**
* Property store - Zustand store for property state management
*/
import { create } from 'zustand'
import type { Property } from './types'
import {
getActiveProperty,
getUserProperties,
getPropertyById,
} from './actions'
interface PropertyStore {
// State
activeProperty: Property | null
properties: Property[]
isLoading: boolean
error: string | null
// Actions
fetchProperties: () => Promise<void>
fetchActiveProperty: () => Promise<void>
setActiveProperty: (propertyId: string) => Promise<void>
initialize: () => Promise<void>
}
export const usePropertyStore = create<PropertyStore>((set, get) => ({
// Initial state
activeProperty: null,
properties: [],
isLoading: true,
error: null,
// Fetch all properties
fetchProperties: async () => {
const result = await getUserProperties()
if (result.success) {
set({ properties: result.data || [], error: null })
} else {
set({ error: result.error || 'Failed to fetch properties', properties: [] })
}
},
// Fetch the active property from database
fetchActiveProperty: async () => {
set({ isLoading: true })
const result = await getActiveProperty()
if (result.success) {
set({
activeProperty: result.data || null,
isLoading: false,
error: null
})
// 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',
activeProperty: null,
isLoading: false
})
}
},
// Set active property by fetching it directly by ID (URL-based, no session update)
setActiveProperty: async (propertyId: string) => {
set({ isLoading: true })
const result = await getPropertyById(propertyId)
if (result.success && result.data) {
set({ activeProperty: result.data, isLoading: false, error: null })
} else {
set({ isLoading: false, error: result.error || 'Property not found' })
}
},
// Initialize - fetch both properties and active property
initialize: async () => {
set({ isLoading: true })
await Promise.all([
get().fetchProperties(),
get().fetchActiveProperty(),
])
},
}))
+5 -5
View File
@@ -1,13 +1,13 @@
/**
* Navigation helpers for property-based routing
* Navigation helpers for project-based routing
*/
export function getEditorUrl(propertyId: string): string {
return `/editor/${propertyId}`
export function getEditorUrl(projectId: string): string {
return `/editor/${projectId}`
}
export function getViewerUrl(propertyId: string): string {
return `/viewer/${propertyId}`
export function getViewerUrl(projectId: string): string {
return `/viewer/${projectId}`
}
export function getHomeUrl(): string {
+1 -1
View File
@@ -56,7 +56,7 @@ export interface CameraControlEvent {
}
export interface ThumbnailGenerateEvent {
propertyId: string
projectId: string
}
type CameraControlEvents = {
+2 -2
View File
@@ -13,8 +13,8 @@ export const sessions = pgTable('auth_sessions', (t) => ({
token: t.text('token').notNull(),
ipAddress: t.text('ip_address'),
userAgent: t.text('user_agent'),
// Custom: active property for the session context
activePropertyId: t.text('active_property_id'),
// Custom: active project for the session context
activeProjectId: t.text('active_project_id'),
// Admin plugin support: tracks who is impersonating this session
impersonatedBy: t
.text('impersonated_by')
+5 -4
View File
@@ -5,7 +5,8 @@ export * from './auth/sessions'
export * from './auth/users'
export * from './auth/verifications'
// Property tables
export * from './properties/addresses'
export * from './properties/models'
export * from './properties/properties'
// Project tables
export * from './projects/addresses'
export * from './projects/likes'
export * from './projects/models'
export * from './projects/projects'
@@ -3,7 +3,7 @@ import { z } from 'zod'
import { id, timestampsColumns } from '../../helpers'
export const addresses = pgTable(
'properties_addresses',
'projects_addresses',
(t) => ({
id: id('address'),
streetNumber: t.text('street_number'),
+36
View File
@@ -0,0 +1,36 @@
import { relations } from 'drizzle-orm'
import { pgTable, unique } from 'drizzle-orm/pg-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { id, createdAt } from '../../helpers'
import { projects } from './projects'
import { users } from '../auth/users'
export const projectsLikes = pgTable(
'projects_likes',
(t) => ({
id: id('like'),
projectId: t
.text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
userId: t
.text('user_id')
.notNull(),
createdAt,
}),
(t) => [
unique('projects_likes_project_user_unique').on(t.projectId, t.userId),
],
).enableRLS()
export const projectsLikesRelations = relations(projectsLikes, ({ one }) => ({
project: one(projects, {
fields: [projectsLikes.projectId],
references: [projects.id],
}),
}))
export type ProjectLike = typeof projectsLikes.$inferSelect
export type NewProjectLike = typeof projectsLikes.$inferInsert
export const insertProjectLikeSchema = createInsertSchema(projectsLikes)
export const selectProjectLikeSchema = createSelectSchema(projectsLikes)
@@ -2,26 +2,26 @@ import { relations } from 'drizzle-orm'
import { pgTable } from 'drizzle-orm/pg-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { id, timestampsColumnsSoftDelete } from '../../helpers'
import { properties } from './properties'
import { projects } from './projects'
export const models = pgTable('properties_models', (t) => ({
export const models = pgTable('projects_models', (t) => ({
id: id('model'),
name: t.text('name'),
version: t.integer('version').default(1),
description: t.text('description'),
draft: t.boolean('draft').default(true),
propertyId: t
.text('property_id')
.references(() => properties.id, { onDelete: 'set null' }),
projectId: t
.text('project_id')
.references(() => projects.id, { onDelete: 'set null' }),
sceneGraph: t.jsonb('scene_graph'),
metadata: t.jsonb('metadata'),
...timestampsColumnsSoftDelete,
})).enableRLS()
export const modelsRelations = relations(models, ({ one }) => ({
property: one(properties, {
fields: [models.propertyId],
references: [properties.id],
project: one(projects, {
fields: [models.projectId],
references: [projects.id],
}),
}))
@@ -5,15 +5,14 @@ import { id, timestampsColumns } from '../../helpers'
import { users } from '../auth/users'
import { addresses } from './addresses'
export const properties = pgTable(
'properties',
export const projects = pgTable(
'projects',
(t) => ({
id: id('property'),
id: id('project'),
name: t.text('name'),
addressId: t
.text('address_id')
.references(() => addresses.id, { onDelete: 'set null' })
.unique(),
.references(() => addresses.id, { onDelete: 'set null' }),
ownerId: t
.text('owner_id')
.references(() => users.id, { onDelete: 'set null' }),
@@ -27,26 +26,26 @@ export const properties = pgTable(
...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),
index('project_address_idx').on(t.addressId),
index('project_owner_idx').on(t.ownerId),
index('project_is_private_idx').on(t.isPrivate),
index('project_views_idx').on(t.views),
index('project_likes_idx').on(t.likes),
],
).enableRLS()
export const propertiesRelations = relations(properties, ({ one }) => ({
export const projectsRelations = relations(projects, ({ one }) => ({
address: one(addresses, {
fields: [properties.addressId],
fields: [projects.addressId],
references: [addresses.id],
}),
owner: one(users, {
fields: [properties.ownerId],
fields: [projects.ownerId],
references: [users.id],
}),
}))
export type Property = typeof properties.$inferSelect
export type NewProperty = typeof properties.$inferInsert
export const insertPropertySchema = createInsertSchema(properties)
export const selectPropertySchema = createSelectSchema(properties)
export type Project = typeof projects.$inferSelect
export type NewProject = typeof projects.$inferInsert
export const insertProjectSchema = createInsertSchema(projects)
export const selectProjectSchema = createSelectSchema(projects)
+9 -9
View File
@@ -8,7 +8,7 @@ export type Json = string | number | boolean | null | { [key: string]: Json | un
export interface Database {
public: {
Tables: {
properties: {
projects: {
Row: {
id: string
name: string
@@ -31,10 +31,10 @@ export interface Database {
updated_at?: string
}
}
properties_addresses: {
projects_addresses: {
Row: {
id: string
property_id: string
project_id: string
formatted_address: string
street_number: string | null
route: string | null
@@ -51,7 +51,7 @@ export interface Database {
}
Insert: {
id?: string
property_id: string
project_id: string
formatted_address: string
street_number?: string | null
route?: string | null
@@ -68,7 +68,7 @@ export interface Database {
}
Update: {
id?: string
property_id?: string
project_id?: string
formatted_address?: string
street_number?: string | null
route?: string | null
@@ -84,10 +84,10 @@ export interface Database {
updated_at?: string
}
}
properties_models: {
projects_models: {
Row: {
id: string
property_id: string
project_id: string
name: string
version: number
draft: boolean
@@ -97,7 +97,7 @@ export interface Database {
}
Insert: {
id?: string
property_id: string
project_id: string
name: string
version?: number
draft?: boolean
@@ -107,7 +107,7 @@ export interface Database {
}
Update: {
id?: string
property_id?: string
project_id?: string
name?: string
version?: number
draft?: boolean
@@ -0,0 +1,247 @@
-- Migration: Rename properties → projects
-- This renames all property-related tables, columns, indexes, policies, and functions.
BEGIN;
-- ============================================================
-- 1. Drop existing RLS policies (they reference old table names)
-- ============================================================
-- properties policies
DROP POLICY IF EXISTS "Users can view own or public 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;
-- properties_addresses policies
DROP POLICY IF EXISTS "Authenticated users can view all addresses" ON properties_addresses;
DROP POLICY IF EXISTS "Authenticated users can insert addresses" ON properties_addresses;
DROP POLICY IF EXISTS "Authenticated users can update addresses" ON properties_addresses;
DROP POLICY IF EXISTS "Authenticated users can delete addresses" ON properties_addresses;
-- properties_models policies
DROP POLICY IF EXISTS "Users can view models of own or public 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;
-- property_likes policies
DROP POLICY IF EXISTS "Anyone can view likes" ON property_likes;
DROP POLICY IF EXISTS "Users can create their own likes" ON property_likes;
DROP POLICY IF EXISTS "Users can delete their own likes" ON property_likes;
-- ============================================================
-- 2. Drop old indexes (before renaming tables)
-- ============================================================
DROP INDEX IF EXISTS idx_properties_owner_id;
DROP INDEX IF EXISTS idx_properties_address_id;
DROP INDEX IF EXISTS idx_properties_is_private;
DROP INDEX IF EXISTS idx_properties_views;
DROP INDEX IF EXISTS idx_properties_likes;
DROP INDEX IF EXISTS idx_properties_addresses_city_state;
DROP INDEX IF EXISTS idx_properties_models_property_id;
DROP INDEX IF EXISTS idx_properties_models_version;
DROP INDEX IF EXISTS idx_property_likes_property_id;
DROP INDEX IF EXISTS idx_property_likes_user_id;
DROP INDEX IF EXISTS property_address_idx;
DROP INDEX IF EXISTS property_owner_idx;
DROP INDEX IF EXISTS property_is_private_idx;
DROP INDEX IF EXISTS property_views_idx;
DROP INDEX IF EXISTS property_likes_idx;
-- ============================================================
-- 3. Drop old triggers (before renaming tables)
-- ============================================================
DROP TRIGGER IF EXISTS update_properties_updated_at ON properties;
DROP TRIGGER IF EXISTS update_properties_addresses_updated_at ON properties_addresses;
DROP TRIGGER IF EXISTS update_properties_models_updated_at ON properties_models;
-- ============================================================
-- 4. Rename tables
-- ============================================================
ALTER TABLE properties_addresses RENAME TO projects_addresses;
ALTER TABLE properties_models RENAME TO projects_models;
ALTER TABLE property_likes RENAME TO projects_likes;
ALTER TABLE properties RENAME TO projects;
-- ============================================================
-- 5. Rename columns (property_id → project_id)
-- ============================================================
ALTER TABLE projects_models RENAME COLUMN property_id TO project_id;
ALTER TABLE projects_likes RENAME COLUMN property_id TO project_id;
-- Rename active_property_id in auth_sessions
ALTER TABLE auth_sessions RENAME COLUMN active_property_id TO active_project_id;
-- ============================================================
-- 6. Rename constraints
-- ============================================================
-- Rename the unique constraint on projects_likes
ALTER TABLE projects_likes RENAME CONSTRAINT "property_likes_pkey" TO "projects_likes_pkey";
ALTER TABLE projects_likes RENAME CONSTRAINT "property_likes_property_id_fkey" TO "projects_likes_project_id_fkey";
ALTER TABLE projects_likes RENAME CONSTRAINT "property_likes_property_id_user_id_key" TO "projects_likes_project_id_user_id_key";
-- ============================================================
-- 7. Recreate indexes with new names
-- ============================================================
CREATE INDEX project_address_idx ON projects(address_id);
CREATE INDEX project_owner_idx ON projects(owner_id);
CREATE INDEX project_is_private_idx ON projects(is_private) WHERE is_private = false;
CREATE INDEX project_views_idx ON projects(views DESC);
CREATE INDEX project_likes_idx ON projects(likes DESC);
CREATE INDEX idx_projects_addresses_city_state ON projects_addresses(city, state);
CREATE INDEX idx_projects_models_project_id ON projects_models(project_id);
CREATE INDEX idx_projects_models_version ON projects_models(project_id, version DESC);
CREATE INDEX idx_projects_likes_project_id ON projects_likes(project_id);
CREATE INDEX idx_projects_likes_user_id ON projects_likes(user_id);
-- ============================================================
-- 8. Recreate triggers on renamed tables
-- ============================================================
CREATE TRIGGER update_projects_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_addresses_updated_at
BEFORE UPDATE ON projects_addresses
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_models_updated_at
BEFORE UPDATE ON projects_models
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 9. Recreate RLS policies with new names and table references
-- ============================================================
-- projects policies
CREATE POLICY "Users can view own or public projects"
ON projects FOR SELECT
USING (
owner_id = current_setting('app.user_id', true)::TEXT
OR is_private = false
OR owner_id IS NULL
);
CREATE POLICY "Users can insert their own projects"
ON projects 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 projects"
ON projects FOR UPDATE
USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
CREATE POLICY "Users can delete their own projects"
ON projects FOR DELETE
USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
-- projects_addresses policies
CREATE POLICY "Authenticated users can view all addresses"
ON projects_addresses FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can insert addresses"
ON projects_addresses FOR INSERT
WITH CHECK (true);
CREATE POLICY "Authenticated users can update addresses"
ON projects_addresses FOR UPDATE
USING (true);
CREATE POLICY "Authenticated users can delete addresses"
ON projects_addresses FOR DELETE
USING (true);
-- projects_models policies
CREATE POLICY "Users can view models of own or public projects"
ON projects_models FOR SELECT
USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = projects_models.project_id
AND (
projects.owner_id = current_setting('app.user_id', true)::TEXT
OR projects.is_private = false
)
)
);
CREATE POLICY "Users can insert models for their own projects"
ON projects_models FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = projects_models.project_id
AND projects.owner_id = current_setting('app.user_id', true)::TEXT
)
);
CREATE POLICY "Users can update models of their own projects"
ON projects_models FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = projects_models.project_id
AND projects.owner_id = current_setting('app.user_id', true)::TEXT
)
);
CREATE POLICY "Users can delete models of their own projects"
ON projects_models FOR DELETE
USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = projects_models.project_id
AND projects.owner_id = current_setting('app.user_id', true)::TEXT
)
);
-- projects_likes policies
CREATE POLICY "Anyone can view likes"
ON projects_likes FOR SELECT
USING (true);
CREATE POLICY "Users can create their own likes"
ON projects_likes FOR INSERT
WITH CHECK (user_id = current_setting('app.user_id', true)::TEXT);
CREATE POLICY "Users can delete their own likes"
ON projects_likes FOR DELETE
USING (user_id = current_setting('app.user_id', true)::TEXT);
-- ============================================================
-- 10. Replace functions with renamed versions
-- ============================================================
-- Drop old functions
DROP FUNCTION IF EXISTS increment_property_views(TEXT);
DROP FUNCTION IF EXISTS get_property_like_count(TEXT);
-- Recreate with new names
CREATE OR REPLACE FUNCTION increment_project_views(project_id TEXT)
RETURNS void AS $$
BEGIN
UPDATE projects
SET views = views + 1
WHERE id = project_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION get_project_like_count(project_id TEXT)
RETURNS INTEGER AS $$
BEGIN
RETURN (SELECT COUNT(*)::INTEGER FROM projects_likes WHERE projects_likes.project_id = $1);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
COMMIT;