Merge pull request #98 from pascalorg/feat/rename-properties-to-projects

feat: rename properties to projects + simplify project creation
This commit is contained in:
Wassim SAMAD
2026-02-18 08:08:58 +09:00
committed by GitHub
36 changed files with 1567 additions and 1228 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?.()
}}
/>
@@ -0,0 +1,167 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updateProjectName, updateProjectPrivacy, deleteProject } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
interface ProjectSettingsDialogProps {
project: Project
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function ProjectSettingsDialog({
project,
open,
onOpenChange,
onUpdate,
onDelete,
}: ProjectSettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [name, setName] = useState(project.name || '')
const [isPrivate, setIsPrivate] = useState(project.is_private)
const handleSave = async () => {
setLoading(true)
try {
// Update name if changed
const trimmedName = name.trim()
if (trimmedName && trimmedName !== (project.name || '')) {
const nameResult = await updateProjectName(project.id, trimmedName)
if (!nameResult.success) {
alert(`Failed to update name: ${nameResult.error}`)
setLoading(false)
return
}
}
// Update privacy if changed
if (isPrivate !== project.is_private) {
const privacyResult = await updateProjectPrivacy(project.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
return
}
}
onUpdate?.()
onOpenChange(false)
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProject(project.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete project: ${result.error}`)
}
} catch (error) {
alert('Failed to delete project')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>Update project name and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Project Name */}
<div>
<label htmlFor="project-name" className="font-medium text-sm">
Project Name
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => setName(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={loading}
/>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a project, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading}
>
{isDeleting ? 'Deleting...' : 'Delete Project'}
</button>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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}
/>
</>
)
}
@@ -1,242 +0,0 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updatePropertyAddress, updatePropertyPrivacy, deleteProperty } from '../lib/properties/actions'
import type { Property } from '../lib/properties/types'
interface PropertySettingsDialogProps {
property: Property
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function PropertySettingsDialog({
property,
open,
onOpenChange,
onUpdate,
onDelete,
}: PropertySettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isPrivate, setIsPrivate] = useState(property.is_private)
const [address, setAddress] = useState({
street_number: property.address.street_number || '',
route: property.address.route || '',
city: property.address.city || '',
state: property.address.state || '',
postal_code: property.address.postal_code || '',
country: property.address.country || 'US',
})
const handleSave = async () => {
setLoading(true)
try {
// Update privacy if changed
if (isPrivate !== property.is_private) {
const privacyResult = await updatePropertyPrivacy(property.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
return
}
}
// Update address if changed
const addressChanged =
address.street_number !== (property.address.street_number || '') ||
address.route !== (property.address.route || '') ||
address.city !== (property.address.city || '') ||
address.state !== (property.address.state || '') ||
address.postal_code !== (property.address.postal_code || '') ||
address.country !== (property.address.country || 'US')
if (addressChanged) {
const addressResult = await updatePropertyAddress(property.id, address)
if (!addressResult.success) {
alert(`Failed to update address: ${addressResult.error}`)
setLoading(false)
return
}
}
onUpdate?.()
onOpenChange(false)
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProperty(property.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete property: ${result.error}`)
}
} catch (error) {
alert('Failed to delete property')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Property Settings</DialogTitle>
<DialogDescription>Update property address and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{/* Address Fields */}
<div className="space-y-4">
<h3 className="font-medium">Address</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Street Number</label>
<input
type="text"
value={address.street_number}
onChange={(e) => setAddress({ ...address, street_number: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="123"
/>
</div>
<div>
<label className="text-sm font-medium">Street</label>
<input
type="text"
value={address.route}
onChange={(e) => setAddress({ ...address, route: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="Main St"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">City</label>
<input
type="text"
value={address.city}
onChange={(e) => setAddress({ ...address, city: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="San Francisco"
/>
</div>
<div>
<label className="text-sm font-medium">State</label>
<input
type="text"
value={address.state}
onChange={(e) => setAddress({ ...address, state: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="CA"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Postal Code</label>
<input
type="text"
value={address.postal_code}
onChange={(e) => setAddress({ ...address, postal_code: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="94102"
/>
</div>
<div>
<label className="text-sm font-medium">Country</label>
<input
type="text"
value={address.country}
onChange={(e) => setAddress({ ...address, country: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="US"
/>
</div>
</div>
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a property, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading}
>
{isDeleting ? 'Deleting...' : 'Delete Property'}
</button>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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])
}
@@ -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;