feat: rename properties to projects

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