community feature

This commit is contained in:
wass08
2026-02-11 15:23:18 +09:00
parent 4f9c4655e6
commit 2e8b663001
71 changed files with 1917 additions and 126 deletions
+198
View File
@@ -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
}