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