community feature
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
# Community Feature
|
||||
|
||||
This directory contains the **optional** community features (cloud synchronization and authentication) for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
|
||||
|
||||
## What This Does
|
||||
|
||||
The community feature provides:
|
||||
|
||||
- **Authentication** - Sign in with magic link via Better Auth
|
||||
- **Property Management** - Create and manage properties with Google Maps address search
|
||||
- **Scene Loading** - Automatically load property scenes from the database when a property is selected
|
||||
- **Auto-Save** - Automatically save scene changes to the database (2-second debounce)
|
||||
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
features/community/
|
||||
├── lib/
|
||||
│ ├── auth/
|
||||
│ │ ├── client.ts # Re-exports from @pascal-app/auth
|
||||
│ │ ├── server.ts # Server-side session handling
|
||||
│ │ └── hooks.ts # useAuth React hook
|
||||
│ ├── properties/
|
||||
│ │ ├── actions.ts # Server actions for CRUD operations
|
||||
│ │ ├── types.ts # TypeScript types for properties
|
||||
│ │ ├── hooks.ts # Property React hooks
|
||||
│ │ └── store.ts # Zustand store for property state
|
||||
│ ├── models/
|
||||
│ │ ├── actions.ts # Scene model CRUD operations
|
||||
│ │ └── hooks.ts # Scene loading and auto-save hooks
|
||||
│ ├── database/
|
||||
│ │ └── server.ts # Re-exports from @pascal-app/db
|
||||
│ └── utils/
|
||||
│ └── id-generator.ts # nanoid-based ID generation
|
||||
├── components/
|
||||
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
|
||||
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
|
||||
│ ├── profile-dropdown.tsx # User profile menu
|
||||
│ ├── property-dropdown.tsx # Property selector dropdown
|
||||
│ ├── new-property-dialog.tsx # Create new property dialog
|
||||
│ └── google-address-search.tsx # Google Maps autocomplete
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authentication Flow
|
||||
1. User clicks "Save to cloud" button
|
||||
2. Signs in with magic link (email-based, no password)
|
||||
3. Better Auth session is stored in cookies
|
||||
4. Server actions validate session using Better Auth API
|
||||
|
||||
### Property Management
|
||||
1. User creates a property with a real-world address (Google Maps)
|
||||
2. Address and property are saved to PostgreSQL via Supabase
|
||||
3. Properties are associated with the authenticated user
|
||||
4. User can switch between properties
|
||||
|
||||
### Scene Management
|
||||
1. When a property is selected, its scene is loaded from `properties_models` table
|
||||
2. If no scene exists, loads default empty scene
|
||||
3. Scene changes are auto-saved every 2 seconds (debounced)
|
||||
4. Updates existing model (highest version) instead of creating new ones
|
||||
5. Scene graph includes all nodes and hierarchy
|
||||
|
||||
### Database Integration
|
||||
- Uses Supabase (PostgreSQL) for database access
|
||||
- Better Auth manages authentication tables directly
|
||||
- Server actions use service role key to bypass RLS
|
||||
- Permissions enforced by filtering on `owner_id`
|
||||
- Tables: `users`, `sessions`, `properties`, `properties_addresses`, `properties_models`
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Database Connection
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Google Maps API Key (for address search)
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
|
||||
```
|
||||
|
||||
Generate `BETTER_AUTH_SECRET`:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The community feature requires these packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"better-auth": "^1.4.18",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"nanoid": "^5.1.6"
|
||||
}
|
||||
```
|
||||
|
||||
## How to Remove (For Open Source Users)
|
||||
|
||||
If you want to use the editor without community features:
|
||||
|
||||
### 1. Delete this directory
|
||||
```bash
|
||||
rm -rf features/community
|
||||
```
|
||||
|
||||
### 2. Remove the CloudSaveButton from the editor
|
||||
Edit `components/editor/index.tsx`:
|
||||
```diff
|
||||
- import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
||||
|
||||
export default function Editor() {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
- <CloudSaveButton />
|
||||
```
|
||||
|
||||
### 3. Remove dependencies (optional)
|
||||
Edit `package.json`:
|
||||
```diff
|
||||
- "better-auth": "^1.4.18",
|
||||
- "@supabase/supabase-js": "^2.95.3",
|
||||
- "@react-google-maps/api": "^2.20.8",
|
||||
- "nanoid": "^5.1.6"
|
||||
```
|
||||
|
||||
### 4. Remove environment variables
|
||||
Delete from `.env.local` and `.env.example`:
|
||||
```diff
|
||||
- NEXT_PUBLIC_API_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
- SUPABASE_SERVICE_ROLE_KEY=...
|
||||
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
That's it! The editor will work as a standalone application without any cloud features.
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
This feature requires:
|
||||
- Supabase local development instance
|
||||
- PostgreSQL database with the following tables:
|
||||
- `users` - User accounts (Better Auth)
|
||||
- `sessions` - Authentication sessions (Better Auth)
|
||||
- `verification_tokens` - Magic link tokens (Better Auth)
|
||||
- `properties` - Property records
|
||||
- `properties_addresses` - Property addresses
|
||||
- `properties_models` - Scene graph models
|
||||
- Database migrations are managed in `packages/db/supabase/migrations/`
|
||||
|
||||
## Development
|
||||
|
||||
To work on this feature:
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
2. Start Supabase local development:
|
||||
```bash
|
||||
bun db:start
|
||||
```
|
||||
3. Run database migrations:
|
||||
```bash
|
||||
bun db:reset
|
||||
```
|
||||
4. Configure all environment variables in `apps/editor/.env.local`
|
||||
5. Run the editor: `bun dev`
|
||||
|
||||
The editor will be available at `http://localhost:3000`.
|
||||
|
||||
For detailed setup instructions, see [SETUP.md](../../../SETUP.md) in the root directory.
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature uses **server actions** (Next.js App Router) for all database operations
|
||||
- Authentication is handled by **Better Auth** with magic link support
|
||||
- Better Auth server is configured in `packages/auth` and mounted at `/api/auth/*`
|
||||
- The editor queries the database directly using Supabase with service role key
|
||||
- IDs are generated using nanoid with custom alphabet
|
||||
- Scene state is managed with a **Zustand store** for reliable property switching
|
||||
- Scene changes are auto-saved with 2-second debouncing to the currently selected property
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { Cloud } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import { usePropertyStore } from '../lib/properties/store'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
import { PropertyDropdown } from './property-dropdown'
|
||||
import { SignInDialog } from './sign-in-dialog'
|
||||
|
||||
/**
|
||||
* CloudSaveButton - Shows authentication state and property management
|
||||
*
|
||||
* Not authenticated: Shows "Save to cloud" button
|
||||
* Authenticated: Shows PropertyDropdown and ProfileDropdown
|
||||
*/
|
||||
export function CloudSaveButton() {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
||||
const initialize = usePropertyStore(state => state.initialize)
|
||||
|
||||
// Initialize property store when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
initialize()
|
||||
}
|
||||
}, [isAuthenticated, initialize])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
>
|
||||
<Cloud className="h-4 w-4" />
|
||||
Save to cloud
|
||||
</button>
|
||||
</div>
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<PropertyDropdown />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { Autocomplete, LoadScript } from '@react-google-maps/api'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const libraries: ('places')[] = ['places']
|
||||
|
||||
interface AddressComponents {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
center: [number, number]
|
||||
formattedAddress: string
|
||||
}
|
||||
|
||||
interface GoogleAddressSearchProps {
|
||||
onAddressSelect: (address: AddressComponents) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
|
||||
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
|
||||
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
.pac-container {
|
||||
z-index: 9999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
return () => {
|
||||
document.head.removeChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!apiKey) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<p className="font-medium">Google Maps API Key Missing</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
|
||||
setAutocomplete(autocompleteInstance)
|
||||
}
|
||||
|
||||
const onPlaceChanged = () => {
|
||||
if (!autocomplete) return
|
||||
|
||||
const place = autocomplete.getPlace()
|
||||
if (!place.geometry?.location || !place.address_components) return
|
||||
|
||||
const components: AddressComponents = {
|
||||
center: [place.geometry.location.lng(), place.geometry.location.lat()],
|
||||
formattedAddress: place.formatted_address || '',
|
||||
}
|
||||
|
||||
// Parse address components
|
||||
for (const component of place.address_components) {
|
||||
const types = component.types
|
||||
|
||||
if (types.includes('street_number')) {
|
||||
components.streetNumber = component.long_name
|
||||
} else if (types.includes('route')) {
|
||||
components.route = component.long_name
|
||||
} else if (types.includes('locality')) {
|
||||
components.city = component.long_name
|
||||
} else if (types.includes('administrative_area_level_1')) {
|
||||
components.state = component.short_name
|
||||
} else if (types.includes('postal_code')) {
|
||||
components.postalCode = component.long_name
|
||||
} else if (types.includes('country')) {
|
||||
components.country = component.short_name
|
||||
}
|
||||
}
|
||||
|
||||
onAddressSelect(components)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 font-medium text-sm">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Property Address
|
||||
</label>
|
||||
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
placeholder="Search for an address..."
|
||||
type="text"
|
||||
/>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</LoadScript>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createProperty } from '../lib/properties/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
import { GoogleAddressSearch } from './google-address-search'
|
||||
|
||||
interface NewPropertyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: (propertyId: string) => void
|
||||
}
|
||||
|
||||
interface AddressData {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
center: [number, number]
|
||||
formattedAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
|
||||
*/
|
||||
export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
|
||||
const [address, setAddress] = useState<AddressData | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAddressSelect = (addressData: AddressData) => {
|
||||
setAddress(addressData)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!address) {
|
||||
setError('Please select an address')
|
||||
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',
|
||||
})
|
||||
|
||||
if (result.success && result.data) {
|
||||
onOpenChange(false)
|
||||
setAddress(null)
|
||||
onSuccess?.(result.data.id)
|
||||
} else {
|
||||
setError(result.error || 'Failed to create property')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isCreating) {
|
||||
onOpenChange(false)
|
||||
setAddress(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose} modal={false}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Property</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isCreating}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{/* Google Maps Address Search */}
|
||||
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
|
||||
|
||||
{/* Show selected address */}
|
||||
{address && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium">Selected Address:</p>
|
||||
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</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}
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Property'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileDropdown - User profile menu with sign out
|
||||
*/
|
||||
export function ProfileDropdown() {
|
||||
const { user, signOut } = useAuth()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
// TODO: Show sign-in dialog or redirect
|
||||
console.log('Signed out')
|
||||
}
|
||||
|
||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||||
type="button"
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{user?.name && (
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
<div className="font-medium">{user.name}</div>
|
||||
{user.email && <div className="text-muted-foreground text-xs">{user.email}</div>}
|
||||
</div>
|
||||
)}
|
||||
{user?.name && <DropdownMenuItem className="h-px bg-border" />}
|
||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { Mail, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '../lib/auth/client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
|
||||
interface SignInDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* SignInDialog - Magic link authentication dialog
|
||||
*/
|
||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Use better-auth's magic link sign in
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || 'Failed to send magic link')
|
||||
} else {
|
||||
setSuccess(true)
|
||||
setEmail('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
onOpenChange(false)
|
||||
// Reset state after a short delay to avoid flash
|
||||
setTimeout(() => {
|
||||
setEmail('')
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isLoading}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
{success ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
|
||||
<Mail className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Check your email</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We've sent a magic link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click the link in the email to sign in to your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="w-full rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isLoading || !email}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
Sending magic link...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
We'll send you a magic link to sign in without a password.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Auth client for the editor using better-auth
|
||||
* Re-exports from @pascal-app/auth package
|
||||
*/
|
||||
|
||||
export { authClient } from '@pascal-app/auth/client'
|
||||
export type { AuthState, User, Session } from '@pascal-app/auth/client'
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { authClient } from './client'
|
||||
|
||||
/**
|
||||
* Hook to access authentication state using better-auth
|
||||
* @returns Current auth state including user, session, and loading status
|
||||
*/
|
||||
export function useAuth() {
|
||||
const session = authClient.useSession()
|
||||
|
||||
return {
|
||||
user: session.data?.user ?? null,
|
||||
session: session.data?.session ?? null,
|
||||
isAuthenticated: !!session.data?.user && !!session.data?.session,
|
||||
isLoading: session.isPending,
|
||||
signOut: () => authClient.signOut(),
|
||||
signIn: authClient.signIn,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { headers as nextHeaders } from 'next/headers'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
||||
|
||||
/**
|
||||
* Get the current session from Better Auth backend (server-side)
|
||||
*/
|
||||
export async function getSession() {
|
||||
try {
|
||||
const headersList = await nextHeaders()
|
||||
|
||||
// Make authenticated request to the auth backend to get session
|
||||
const response = await fetch(`${API_URL}/api/auth/get-session`, {
|
||||
headers: {
|
||||
cookie: headersList.get('cookie') || '',
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Better Auth returns the session data directly
|
||||
if (data?.user && data?.session) {
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to get session:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from the session
|
||||
*/
|
||||
export async function getUser() {
|
||||
const session = await getSession()
|
||||
return session?.user ?? null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Supabase server client for database access
|
||||
* Re-exports from @pascal-app/db package
|
||||
*/
|
||||
|
||||
import { supabaseAdmin } from '@pascal-app/db/server'
|
||||
|
||||
/**
|
||||
* Create a Supabase client for server-side use with service role key
|
||||
* This bypasses RLS and allows server actions to query the database directly
|
||||
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
|
||||
*/
|
||||
export async function createServerSupabaseClient() {
|
||||
return supabaseAdmin
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Property model actions - Server actions for scene loading/saving
|
||||
* Manages 3D models (scene graphs) stored in properties_models table
|
||||
*/
|
||||
|
||||
'use server'
|
||||
|
||||
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'
|
||||
|
||||
export interface SceneGraph {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
}
|
||||
|
||||
export interface PropertyModel {
|
||||
id: string
|
||||
name: string
|
||||
version: number
|
||||
draft: boolean
|
||||
property_id: string
|
||||
scene_graph: SceneGraph | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest model for a property (highest version)
|
||||
*/
|
||||
export async function getPropertyModel(propertyId: string): Promise<ActionResult<PropertyModel | null>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Get the property to verify ownership
|
||||
const { data: property, error: propertyError } = await supabase
|
||||
.from('properties')
|
||||
.select('id, owner_id')
|
||||
.eq('id', propertyId)
|
||||
.single()
|
||||
|
||||
if (propertyError || !property) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Property not found',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (property.owner_id !== session.user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the latest model (highest version, then most recent)
|
||||
const { data: model, error: modelError } = await supabase
|
||||
.from('properties_models')
|
||||
.select('*')
|
||||
.eq('property_id', propertyId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
console.log('[getPropertyModel] Query result:', {
|
||||
propertyId,
|
||||
hasModel: !!model,
|
||||
modelError: modelError?.message,
|
||||
errorCode: modelError?.code,
|
||||
modelKeys: model ? Object.keys(model) : [],
|
||||
})
|
||||
|
||||
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')
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[getPropertyModel] Database error:', modelError)
|
||||
return {
|
||||
success: false,
|
||||
error: modelError.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[getPropertyModel] Model found:', {
|
||||
id: model.id,
|
||||
version: model.version,
|
||||
hasSceneGraph: !!model.scene_graph,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: model as PropertyModel,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch property model',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a property model's scene graph
|
||||
* If a model exists, updates it. Otherwise creates a new one.
|
||||
*/
|
||||
export async function savePropertyModel(
|
||||
propertyId: string,
|
||||
sceneGraph: SceneGraph,
|
||||
): Promise<ActionResult<PropertyModel>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Get the property to verify ownership
|
||||
const { data: property, error: propertyError } = await supabase
|
||||
.from('properties')
|
||||
.select('id, owner_id, name')
|
||||
.eq('id', propertyId)
|
||||
.single()
|
||||
|
||||
if (propertyError || !property) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Property not found',
|
||||
}
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (property.owner_id !== session.user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a model already exists
|
||||
const { data: existingModel } = await supabase
|
||||
.from('properties_models')
|
||||
.select('id, version')
|
||||
.eq('property_id', propertyId)
|
||||
.is('deleted_at', null)
|
||||
.order('version', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (existingModel) {
|
||||
// Update existing model
|
||||
const { data: updatedModel, error: updateError } = await supabase
|
||||
.from('properties_models')
|
||||
.update({
|
||||
scene_graph: sceneGraph as any,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existingModel.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return {
|
||||
success: false,
|
||||
error: updateError.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedModel as PropertyModel,
|
||||
message: 'Model updated successfully',
|
||||
}
|
||||
} else {
|
||||
// Create new model
|
||||
const modelId = createId('model')
|
||||
|
||||
const { data: newModel, error: createError } = await supabase
|
||||
.from('properties_models')
|
||||
.insert({
|
||||
id: modelId,
|
||||
property_id: propertyId,
|
||||
name: `${property.name} - Editor`,
|
||||
version: 1,
|
||||
draft: true,
|
||||
scene_graph: sceneGraph as any,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (createError) {
|
||||
return {
|
||||
success: false,
|
||||
error: createError.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: newModel as PropertyModel,
|
||||
message: 'Model created successfully',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save property model',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Hooks for property model (scene) loading and auto-saving
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* Load the scene when a property 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)
|
||||
|
||||
const lastPropertyIdRef = useRef<string | null>(null)
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const isSavingRef = useRef(false)
|
||||
const currentPropertyIdRef = useRef<string | null>(null)
|
||||
|
||||
// Extract property ID for dependency tracking
|
||||
const propertyId = activeProperty?.id ?? null
|
||||
const propertyName = activeProperty?.name ?? null
|
||||
|
||||
// Load scene when active property changes
|
||||
useEffect(() => {
|
||||
if (isLoadingProperty) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!propertyId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if same property
|
||||
if (lastPropertyIdRef.current === propertyId) {
|
||||
return
|
||||
}
|
||||
|
||||
lastPropertyIdRef.current = propertyId
|
||||
|
||||
// Load the property's scene
|
||||
async function loadScene() {
|
||||
try {
|
||||
const result = await getPropertyModel(propertyId || '')
|
||||
|
||||
if (result.success && result.data?.scene_graph) {
|
||||
// Load the scene graph into the store
|
||||
const { nodes, rootNodeIds } = result.data.scene_graph
|
||||
useScene.getState().setScene(nodes, rootNodeIds)
|
||||
} else {
|
||||
// No scene found - clear the scene
|
||||
useScene.getState().clearScene()
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to clear scene
|
||||
useScene.getState().clearScene()
|
||||
}
|
||||
|
||||
// Reset editor state after loading/clearing scene
|
||||
useEditor.getState().setPhase('site')
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: null,
|
||||
levelId: null,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
}
|
||||
|
||||
loadScene()
|
||||
}, [propertyId, isLoadingProperty])
|
||||
|
||||
// Auto-save scene changes with debouncing
|
||||
useEffect(() => {
|
||||
if (!propertyId) {
|
||||
currentPropertyIdRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
currentPropertyIdRef.current = propertyId
|
||||
|
||||
// Subscribe to any scene changes
|
||||
// Use JSON stringification to detect any node changes, not just count
|
||||
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
||||
|
||||
const unsubscribe = useScene.subscribe((state) => {
|
||||
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
||||
|
||||
// Only trigger save if nodes actually changed
|
||||
if (currentNodesSnapshot === lastNodesSnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
lastNodesSnapshot = currentNodesSnapshot
|
||||
const nodes = state.nodes
|
||||
|
||||
// Skip if currently saving
|
||||
if (isSavingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootNodeIds = useScene.getState().rootNodeIds
|
||||
const sceneGraph = { nodes, rootNodeIds }
|
||||
|
||||
isSavingRef.current = true
|
||||
|
||||
try {
|
||||
await savePropertyModel(currentPropertyId, sceneGraph)
|
||||
} finally {
|
||||
isSavingRef.current = false
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
unsubscribe()
|
||||
}
|
||||
}, [propertyId])
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Property actions - Server actions for property management
|
||||
* Uses Better Auth session + Supabase to query the same database as the monorepo
|
||||
*/
|
||||
|
||||
'use server'
|
||||
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
import { createId } from '../utils/id-generator'
|
||||
import type { CreatePropertyParams, Property } from './types'
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all properties for the current user
|
||||
*/
|
||||
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Query properties table with address relation
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.eq('owner_id', session.user.id)
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property[],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active property for the current session
|
||||
*/
|
||||
export async function getActiveProperty(): Promise<ActionResult<Property | null>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Get session's active_property_id from sessions table
|
||||
const { data: sessionData, error: sessionError } = await supabase
|
||||
.from('auth_sessions')
|
||||
.select('active_property_id')
|
||||
.eq('user_id', session.user.id)
|
||||
.single()
|
||||
|
||||
if (sessionError || !sessionData?.active_property_id) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the property with address
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.eq('id', sessionData.active_property_id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active property for the current session
|
||||
*/
|
||||
export async function setActiveProperty(propertyId: string | null): Promise<ActionResult> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Update session's active_property_id
|
||||
const { error } = await supabase
|
||||
.from('auth_sessions')
|
||||
.update({ active_property_id: propertyId })
|
||||
.eq('user_id', session.user.id)
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: propertyId ? 'Active property updated' : 'Active property cleared',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new property
|
||||
*/
|
||||
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Generate IDs for address and property
|
||||
const addressId = createId('address')
|
||||
const propertyId = createId('property')
|
||||
|
||||
// First, create the address
|
||||
const { data: address, error: addressError } = await supabase
|
||||
.from('properties_addresses')
|
||||
.insert({
|
||||
id: addressId,
|
||||
street_number: params.streetNumber,
|
||||
route: params.route,
|
||||
city: params.city || '',
|
||||
state: params.state || '',
|
||||
postal_code: params.postalCode || '',
|
||||
country: params.country || 'US',
|
||||
latitude: params.center[1].toString(),
|
||||
longitude: params.center[0].toString(),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (addressError || !address) {
|
||||
return {
|
||||
success: false,
|
||||
error: addressError?.message || 'Failed to create address',
|
||||
}
|
||||
}
|
||||
|
||||
// Create the property
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.insert({
|
||||
id: propertyId,
|
||||
name: params.name,
|
||||
address_id: address.id,
|
||||
owner_id: session.user.id,
|
||||
details_json: {
|
||||
coordinates: params.center,
|
||||
createdFrom: 'editor-app',
|
||||
},
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property,
|
||||
message: 'Property created successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property with the given address already exists
|
||||
*/
|
||||
export async function checkPropertyDuplicate(params: {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
}): Promise<
|
||||
ActionResult<{
|
||||
isDuplicate: boolean
|
||||
isUserProperty?: boolean
|
||||
existingProperty?: Property
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Query for existing property with matching address
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses!inner(*)
|
||||
`)
|
||||
.eq('address.street_number', params.streetNumber || '')
|
||||
.eq('address.route', params.route || '')
|
||||
.eq('address.city', params.city || '')
|
||||
.eq('address.state', params.state || '')
|
||||
.eq('address.postal_code', params.postalCode || '')
|
||||
.limit(1)
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const existingProperty = data[0] as Property
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isDuplicate: true,
|
||||
isUserProperty: existingProperty.ownerId === session.user.id,
|
||||
existingProperty,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isDuplicate: false,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getActiveProperty,
|
||||
getUserProperties,
|
||||
setActiveProperty as setActivePropertyAction,
|
||||
} from './actions'
|
||||
import type { Property } from './types'
|
||||
|
||||
interface UsePropertiesReturn {
|
||||
properties: Property[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage user properties
|
||||
*/
|
||||
export function useProperties(): UsePropertiesReturn {
|
||||
const [properties, setProperties] = useState<Property[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getUserProperties()
|
||||
|
||||
if (result.success) {
|
||||
setProperties(result.data || [])
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch properties')
|
||||
setProperties([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setProperties([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProperties()
|
||||
}, [fetchProperties])
|
||||
|
||||
return {
|
||||
properties,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchProperties,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single property by ID
|
||||
*/
|
||||
export function useProperty(propertyId: string | undefined) {
|
||||
const [property, setProperty] = useState<Property | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!propertyId) {
|
||||
setProperty(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchProperty = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getUserProperties()
|
||||
|
||||
if (result.success) {
|
||||
const found = result.data?.find((p) => p.id === propertyId)
|
||||
if (found) {
|
||||
setProperty(found)
|
||||
} else {
|
||||
setError('Property not found')
|
||||
setProperty(null)
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch property')
|
||||
setProperty(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setProperty(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperty()
|
||||
}, [propertyId])
|
||||
|
||||
return {
|
||||
property,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the active property for the current session
|
||||
*/
|
||||
export function useActiveProperty() {
|
||||
const [activeProperty, setActivePropertyState] = useState<Property | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isInitialFetchRef = useRef(true)
|
||||
|
||||
const fetchActiveProperty = useCallback(async (allowAutoSelect = false) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getActiveProperty()
|
||||
|
||||
if (result.success) {
|
||||
setActivePropertyState(result.data || null)
|
||||
|
||||
// If no active property is set, automatically set the first property as active
|
||||
// Only do this on initial mount to avoid interfering with property selection/creation
|
||||
if (!result.data && allowAutoSelect) {
|
||||
console.log('[useActiveProperty] No active property, checking if we should auto-select')
|
||||
const propertiesResult = await getUserProperties()
|
||||
|
||||
if (
|
||||
propertiesResult.success &&
|
||||
propertiesResult.data &&
|
||||
propertiesResult.data.length > 0
|
||||
) {
|
||||
console.log('[useActiveProperty] Found properties, auto-selecting first one')
|
||||
const firstProperty = propertiesResult.data[0]
|
||||
if (firstProperty) {
|
||||
const setActiveResult = await setActivePropertyAction(firstProperty.id)
|
||||
|
||||
if (setActiveResult.success) {
|
||||
console.log('[useActiveProperty] Auto-selected property:', firstProperty.name)
|
||||
setActivePropertyState(firstProperty)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!result.data && !allowAutoSelect) {
|
||||
console.log('[useActiveProperty] No active property but auto-select is disabled')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch active property')
|
||||
setActivePropertyState(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setActivePropertyState(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeActiveProperty = useCallback(
|
||||
async (propertyId: string | null) => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setIsLoading(true)
|
||||
const result = await setActivePropertyAction(propertyId)
|
||||
|
||||
if (result.success) {
|
||||
if (propertyId) {
|
||||
// Fetch the property data and set it immediately
|
||||
console.log('[useActiveProperty] Fetching property data for ID:', propertyId)
|
||||
const propertiesResult = await getUserProperties()
|
||||
|
||||
if (propertiesResult.success && propertiesResult.data) {
|
||||
const selectedProperty = propertiesResult.data.find(p => p.id === propertyId)
|
||||
if (selectedProperty) {
|
||||
console.log('[useActiveProperty] Found property, setting as active:', selectedProperty.name)
|
||||
console.log('[useActiveProperty] Current isLoading state:', isLoading)
|
||||
setActivePropertyState(selectedProperty)
|
||||
setIsLoading(false)
|
||||
console.log('[useActiveProperty] Set isLoading to false')
|
||||
} else {
|
||||
console.error('[useActiveProperty] Property not found in user properties')
|
||||
// Fall back to refetch
|
||||
await fetchActiveProperty(false)
|
||||
}
|
||||
} else {
|
||||
console.error('[useActiveProperty] Failed to fetch properties')
|
||||
// Fall back to refetch
|
||||
await fetchActiveProperty(false)
|
||||
}
|
||||
} else {
|
||||
setActivePropertyState(null)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} else {
|
||||
console.error(result.error || 'Failed to update active property')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
},
|
||||
[fetchActiveProperty],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Only allow auto-select on the initial mount
|
||||
const allowAutoSelect = isInitialFetchRef.current
|
||||
if (isInitialFetchRef.current) {
|
||||
isInitialFetchRef.current = false
|
||||
}
|
||||
fetchActiveProperty(allowAutoSelect)
|
||||
}, [fetchActiveProperty])
|
||||
|
||||
return {
|
||||
activeProperty,
|
||||
isLoading,
|
||||
error,
|
||||
setActiveProperty: changeActiveProperty,
|
||||
isPending,
|
||||
refetch: fetchActiveProperty,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Property store - Zustand store for property state management
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { Property } from './types'
|
||||
import {
|
||||
getActiveProperty,
|
||||
getUserProperties,
|
||||
setActiveProperty as setActivePropertyAction,
|
||||
} 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
|
||||
})
|
||||
|
||||
// If no active property, auto-select the first one
|
||||
if (!result.data) {
|
||||
const propertiesResult = await getUserProperties()
|
||||
if (propertiesResult.success && propertiesResult.data && propertiesResult.data.length > 0) {
|
||||
const firstProperty = propertiesResult.data[0]
|
||||
if (firstProperty) {
|
||||
await get().setActiveProperty(firstProperty.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
error: result.error || 'Failed to fetch active property',
|
||||
activeProperty: null,
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Set active property
|
||||
setActiveProperty: async (propertyId: string) => {
|
||||
set({ isLoading: true })
|
||||
|
||||
// Update database
|
||||
const result = await setActivePropertyAction(propertyId)
|
||||
|
||||
if (result.success) {
|
||||
// Fetch properties to get the full property object
|
||||
const propertiesResult = await getUserProperties()
|
||||
|
||||
if (propertiesResult.success && propertiesResult.data) {
|
||||
const selectedProperty = propertiesResult.data.find(p => p.id === propertyId)
|
||||
|
||||
if (selectedProperty) {
|
||||
set({
|
||||
activeProperty: selectedProperty,
|
||||
properties: propertiesResult.data,
|
||||
isLoading: false,
|
||||
error: null
|
||||
})
|
||||
} else {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: 'Property not found'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch properties'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: result.error || 'Failed to set active property'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Initialize - fetch both properties and active property
|
||||
initialize: async () => {
|
||||
set({ isLoading: true })
|
||||
await Promise.all([
|
||||
get().fetchProperties(),
|
||||
get().fetchActiveProperty(),
|
||||
])
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Property-related type definitions
|
||||
* Isolated from monorepo database schema
|
||||
*/
|
||||
|
||||
export type Property = {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
organizationId: string | null
|
||||
addressId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
address: {
|
||||
id: string
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatePropertyParams = {
|
||||
name: string
|
||||
center: [number, number]
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
routeShort?: string
|
||||
neighborhood?: string
|
||||
city?: string
|
||||
county?: string
|
||||
state?: string
|
||||
stateLong?: string
|
||||
postalCode?: string
|
||||
postalCodeSuffix?: string
|
||||
country?: string
|
||||
countryLong?: string
|
||||
rawJson?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const nanoid = customAlphabet(alphabet, 16)
|
||||
|
||||
/**
|
||||
* Generate a unique ID with optional prefix (matches monorepo implementation)
|
||||
* @example createId('user') => 'user_Abc123...'
|
||||
*/
|
||||
export const createId = (prefix?: string) => {
|
||||
const id = nanoid()
|
||||
return prefix ? `${prefix}_${id}` : id
|
||||
}
|
||||
Reference in New Issue
Block a user