diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 523f8a5e..d1755205 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -7,7 +7,7 @@ import useEditor from '@/store/use-editor' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' -import { CloudSaveButton } from '../ui/cloud-save-button' +import { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button' import { PanelManager } from '../ui/panels/panel-manager' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' diff --git a/apps/editor/features/cloud-sync/README.md b/apps/editor/features/cloud-sync/README.md new file mode 100644 index 00000000..ea17757f --- /dev/null +++ b/apps/editor/features/cloud-sync/README.md @@ -0,0 +1,159 @@ +# Cloud Sync Feature + +This directory contains the **optional** cloud synchronization and authentication features 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 cloud sync feature provides: + +- **Authentication** - Sign in with magic link via Better Auth +- **Property Management** - Create and manage properties with Google Maps address search +- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase + +## Architecture + +``` +features/cloud-sync/ +├── lib/ +│ ├── auth/ +│ │ ├── client.ts # Better Auth client configuration +│ │ ├── 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 # useProperties and useActiveProperty hooks +│ ├── database/ +│ │ └── server.ts # Supabase server client with service role +│ └── utils/ +│ └── id-generator.ts # nanoid-based ID generation (matches backend) +├── 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 by calling the monorepo backend + +### 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 + +### Database Integration +- Uses Supabase (PostgreSQL) for database access +- Server actions use service role key to bypass RLS +- Permissions enforced by filtering on `owner_id` +- Table names: `properties`, `properties_addresses`, `auth_sessions` + +## Required Environment Variables + +```bash +# Backend API URL (Pascal monorepo - for better-auth only) +NEXT_PUBLIC_API_URL=http://localhost:3000 + +# 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 + +# Google Maps API Key (for address search) +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here +``` + +## Dependencies + +The cloud sync 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 cloud sync: + +### 1. Delete this directory +```bash +rm -rf features/cloud-sync +``` + +### 2. Remove the CloudSaveButton from the editor +Edit `components/editor/index.tsx`: +```diff +- import { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button' + + export default function Editor() { + return ( +
+ + +- +``` + +### 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 the Pascal monorepo backend running with: +- Better Auth configured with magic link support +- PostgreSQL database with the following tables: + - `properties` - Property records + - `properties_addresses` - Property addresses + - `auth_sessions` - Better Auth sessions + - `auth_users` - Better Auth users +- Supabase local instance for database access + +## Development + +To work on this feature: + +1. Ensure the monorepo backend is running on port 3000 +2. Ensure Supabase local is running on port 54321 +3. Configure all environment variables +4. Run the editor: `bun dev` + +The editor will be available at `http://localhost:3002` (different port to avoid conflicts with the monorepo). + +## Notes + +- This feature uses **server actions** (Next.js App Router) for all database operations +- Authentication is handled by the monorepo backend via Better Auth +- The editor queries the database directly using Supabase with service role key +- IDs are generated using nanoid with custom alphabet to match the backend schema +- All table names match the monorepo's database schema exactly diff --git a/apps/editor/components/ui/cloud-save-button.tsx b/apps/editor/features/cloud-sync/components/cloud-save-button.tsx similarity index 97% rename from apps/editor/components/ui/cloud-save-button.tsx rename to apps/editor/features/cloud-sync/components/cloud-save-button.tsx index 5e7243be..373ae685 100644 --- a/apps/editor/components/ui/cloud-save-button.tsx +++ b/apps/editor/features/cloud-sync/components/cloud-save-button.tsx @@ -2,7 +2,7 @@ import { Cloud } from 'lucide-react' import { useState } from 'react' -import { useAuth } from '@/lib/auth/use-auth' +import { useAuth } from '../lib/auth/hooks' import { ProfileDropdown } from './profile-dropdown' import { PropertyDropdown } from './property-dropdown' import { SignInDialog } from './sign-in-dialog' diff --git a/apps/editor/features/cloud-sync/components/google-address-search.tsx b/apps/editor/features/cloud-sync/components/google-address-search.tsx new file mode 100644 index 00000000..1f08107c --- /dev/null +++ b/apps/editor/features/cloud-sync/components/google-address-search.tsx @@ -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(null) + const inputRef = useRef(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 ( +
+

Google Maps API Key Missing

+

+ Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file +

+
+ ) + } + + 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 ( + +
+ + + + +
+
+ ) +} diff --git a/apps/editor/components/ui/new-property-dialog.tsx b/apps/editor/features/cloud-sync/components/new-property-dialog.tsx similarity index 56% rename from apps/editor/components/ui/new-property-dialog.tsx rename to apps/editor/features/cloud-sync/components/new-property-dialog.tsx index 999b8559..18df3281 100644 --- a/apps/editor/components/ui/new-property-dialog.tsx +++ b/apps/editor/features/cloud-sync/components/new-property-dialog.tsx @@ -2,8 +2,9 @@ import { X } from 'lucide-react' import { useState } from 'react' -import { createProperty } from '@/lib/properties/actions' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog' +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 @@ -11,37 +12,57 @@ interface NewPropertyDialogProps { onSuccess?: () => 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 - * - * TODO: Add Google Maps address search integration - * TODO: Add address parsing and validation - * TODO: Add duplicate checking before creation + * NewPropertyDialog - Dialog for creating a new property with Google Maps address search */ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) { - const [name, setName] = useState('') + const [address, setAddress] = useState(null) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(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 { - // TODO: Replace with actual address data from Google Maps + // Use formatted address as property name (like monorepo) const result = await createProperty({ - name, - center: [0, 0], // TODO: Get from Google Maps - city: '', - state: '', - postalCode: '', - country: 'US', + 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) { onOpenChange(false) - setName('') + setAddress(null) onSuccess?.() } else { setError(result.error || 'Failed to create property') @@ -56,14 +77,17 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty const handleClose = () => { if (!isCreating) { onOpenChange(false) - setName('') + setAddress(null) setError(null) } } return ( - - + + e.preventDefault()} + > Add New Property