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

feat: rename properties to projects + simplify project creation
This commit is contained in:
Wassim SAMAD
2026-02-18 08:08:58 +09:00
committed by GitHub
36 changed files with 1567 additions and 1228 deletions
@@ -0,0 +1,30 @@
'use client'
import Editor from '@/components/editor'
import { useParams } from 'next/navigation'
import { useEffect, useLayoutEffect } from 'react'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { useAuth } from '@/features/community/lib/auth/hooks'
export default function EditorPage() {
const params = useParams()
const projectId = params.projectId as string
const { isAuthenticated } = useAuth()
const setActiveProject = useProjectStore((state) => state.setActiveProject)
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
useLayoutEffect(() => {
// For authenticated users with cloud projects, set the active project from URL
if (isAuthenticated && projectId && !projectId.startsWith('local_')) {
setActiveProject(projectId)
}
}, [projectId, isAuthenticated, setActiveProject])
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor projectId={projectId} />
</div>
</div>
)
}
@@ -1,30 +0,0 @@
'use client'
import Editor from '@/components/editor'
import { useParams } from 'next/navigation'
import { useEffect, useLayoutEffect } from 'react'
import { usePropertyStore } from '@/features/community/lib/properties/store'
import { useAuth } from '@/features/community/lib/auth/hooks'
export default function EditorPage() {
const params = useParams()
const propertyId = params.propertyId as string
const { isAuthenticated } = useAuth()
const setActiveProperty = usePropertyStore((state) => state.setActiveProperty)
// Use layoutEffect to set active property BEFORE the editor renders and hooks run
useLayoutEffect(() => {
// For authenticated users with cloud properties, set the active property from URL
if (isAuthenticated && propertyId && !propertyId.startsWith('local_')) {
setActiveProperty(propertyId)
}
}, [propertyId, isAuthenticated, setActiveProperty])
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor propertyId={propertyId} />
</div>
</div>
)
}
+9 -9
View File
@@ -8,14 +8,14 @@ import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay' import { 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?.()
}} }}
/> />
@@ -0,0 +1,167 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updateProjectName, updateProjectPrivacy, deleteProject } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types'
interface ProjectSettingsDialogProps {
project: Project
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function ProjectSettingsDialog({
project,
open,
onOpenChange,
onUpdate,
onDelete,
}: ProjectSettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [name, setName] = useState(project.name || '')
const [isPrivate, setIsPrivate] = useState(project.is_private)
const handleSave = async () => {
setLoading(true)
try {
// Update name if changed
const trimmedName = name.trim()
if (trimmedName && trimmedName !== (project.name || '')) {
const nameResult = await updateProjectName(project.id, trimmedName)
if (!nameResult.success) {
alert(`Failed to update name: ${nameResult.error}`)
setLoading(false)
return
}
}
// Update privacy if changed
if (isPrivate !== project.is_private) {
const privacyResult = await updateProjectPrivacy(project.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
return
}
}
onUpdate?.()
onOpenChange(false)
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProject(project.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete project: ${result.error}`)
}
} catch (error) {
alert('Failed to delete project')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>Update project name and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Project Name */}
<div>
<label htmlFor="project-name" className="font-medium text-sm">
Project Name
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={loading}
/>
</div>
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this project' : 'Anyone can view this project'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a project, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading}
>
{isDeleting ? 'Deleting...' : 'Delete Project'}
</button>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,109 +0,0 @@
'use client'
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useState } from 'react'
import { usePropertyStore } from '../lib/properties/store'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu'
import { NewPropertyDialog } from './new-property-dialog'
import { usePropertyScene } from '../lib/models/hooks'
/**
* PropertyDropdown - Shows active property and allows switching between properties
*/
export function PropertyDropdown() {
usePropertyScene() // Load and auto-save property scenes
// Use property store
const properties = usePropertyStore(state => state.properties)
const activeProperty = usePropertyStore(state => state.activeProperty)
const isLoading = usePropertyStore(state => state.isLoading)
const setActiveProperty = usePropertyStore(state => state.setActiveProperty)
const fetchProperties = usePropertyStore(state => state.fetchProperties)
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
const handlePropertySelect = async (propertyId: string) => {
await setActiveProperty(propertyId)
}
const handleAddNew = () => {
setIsNewPropertyDialogOpen(true)
}
const handlePropertyCreated = async (propertyId: string) => {
// Set the newly created property as active (this will also fetch properties)
await setActiveProperty(propertyId)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
disabled={isLoading}
type="button"
>
<Home className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{activeProperty
? activeProperty.name
: properties.length > 0
? 'Select Property'
: 'Add Property'}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{/* Property list */}
{properties.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto">
{properties.map((property) => (
<DropdownMenuItem
className={cn(
'cursor-pointer text-sm',
activeProperty?.id === property.id && 'cursor-default bg-accent',
)}
key={property.id}
onClick={() =>
activeProperty?.id === property.id ? null : handlePropertySelect(property.id)
}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{property.name}</div>
{activeProperty?.id === property.id && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</DropdownMenuItem>
))}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
No properties yet
</div>
)}
{/* Add new property option */}
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" />
<span>Add new property</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewPropertyDialog
open={isNewPropertyDialogOpen}
onOpenChange={setIsNewPropertyDialogOpen}
onSuccess={handlePropertyCreated}
/>
</>
)
}
@@ -1,242 +0,0 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updatePropertyAddress, updatePropertyPrivacy, deleteProperty } from '../lib/properties/actions'
import type { Property } from '../lib/properties/types'
interface PropertySettingsDialogProps {
property: Property
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function PropertySettingsDialog({
property,
open,
onOpenChange,
onUpdate,
onDelete,
}: PropertySettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isPrivate, setIsPrivate] = useState(property.is_private)
const [address, setAddress] = useState({
street_number: property.address.street_number || '',
route: property.address.route || '',
city: property.address.city || '',
state: property.address.state || '',
postal_code: property.address.postal_code || '',
country: property.address.country || 'US',
})
const handleSave = async () => {
setLoading(true)
try {
// Update privacy if changed
if (isPrivate !== property.is_private) {
const privacyResult = await updatePropertyPrivacy(property.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
return
}
}
// Update address if changed
const addressChanged =
address.street_number !== (property.address.street_number || '') ||
address.route !== (property.address.route || '') ||
address.city !== (property.address.city || '') ||
address.state !== (property.address.state || '') ||
address.postal_code !== (property.address.postal_code || '') ||
address.country !== (property.address.country || 'US')
if (addressChanged) {
const addressResult = await updatePropertyAddress(property.id, address)
if (!addressResult.success) {
alert(`Failed to update address: ${addressResult.error}`)
setLoading(false)
return
}
}
onUpdate?.()
onOpenChange(false)
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProperty(property.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete property: ${result.error}`)
}
} catch (error) {
alert('Failed to delete property')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Property Settings</DialogTitle>
<DialogDescription>Update property address and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{/* Address Fields */}
<div className="space-y-4">
<h3 className="font-medium">Address</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Street Number</label>
<input
type="text"
value={address.street_number}
onChange={(e) => setAddress({ ...address, street_number: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="123"
/>
</div>
<div>
<label className="text-sm font-medium">Street</label>
<input
type="text"
value={address.route}
onChange={(e) => setAddress({ ...address, route: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="Main St"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">City</label>
<input
type="text"
value={address.city}
onChange={(e) => setAddress({ ...address, city: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="San Francisco"
/>
</div>
<div>
<label className="text-sm font-medium">State</label>
<input
type="text"
value={address.state}
onChange={(e) => setAddress({ ...address, state: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="CA"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Postal Code</label>
<input
type="text"
value={address.postal_code}
onChange={(e) => setAddress({ ...address, postal_code: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="94102"
/>
</div>
<div>
<label className="text-sm font-medium">Country</label>
<input
type="text"
value={address.country}
onChange={(e) => setAddress({ ...address, country: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="US"
/>
</div>
</div>
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a property, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading}
>
{isDeleting ? 'Deleting...' : 'Delete Property'}
</button>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -4,34 +4,34 @@ import { type AnyNodeId, initSpatialGridSync, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { 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])
} }
@@ -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;