isolate cloud-sync feature

This commit is contained in:
wass08
2026-02-11 07:57:10 +09:00
parent c7e5aa48c6
commit c1ff61ad5b
19 changed files with 791 additions and 268 deletions
+1 -1
View File
@@ -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'
+159
View File
@@ -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 (
<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 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
@@ -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'
@@ -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>
)
}
@@ -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<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 {
// 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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<Dialog open={open} onOpenChange={handleClose} modal={false}>
<DialogContent
className="sm:max-w-[500px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Add New Property</DialogTitle>
<button
@@ -77,30 +101,16 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{/* TODO: Add Google Maps address search component */}
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="property-name">
Property Name
</label>
<input
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={isCreating}
id="property-name"
placeholder="Enter property name"
required
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{/* Google Maps Address Search */}
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
{/* TODO: Add address fields with Google Maps autocomplete */}
<div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground text-sm">
<p>TODO: Google Maps address search will be integrated here</p>
<p className="mt-2 text-xs">
For now, property creation is not fully functional. This is a placeholder for the UI.
</p>
</div>
{/* 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">
@@ -119,7 +129,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</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 || !name}
disabled={isCreating || !address}
type="submit"
>
{isCreating ? 'Creating...' : 'Create Property'}
@@ -1,12 +1,12 @@
'use client'
import { useAuth } from '@/lib/auth/use-auth'
import { useAuth } from '../lib/auth/hooks'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
} from '@/components/ui/primitives/dropdown-menu'
function getInitials(name: string): string {
return name
@@ -2,14 +2,14 @@
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useState } from 'react'
import { useActiveProperty, useProperties } from '@/lib/properties/use-properties'
import { useActiveProperty, useProperties } from '../lib/properties/hooks'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
} from '@/components/ui/primitives/dropdown-menu'
import { NewPropertyDialog } from './new-property-dialog'
/**
@@ -2,8 +2,8 @@
import { Mail, X } from 'lucide-react'
import { useState } from 'react'
import { authClient } from '@/lib/auth/auth-client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
import { authClient } from '../lib/auth/client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
interface SignInDialogProps {
open: boolean
@@ -1,6 +1,6 @@
'use client'
import { authClient } from './auth-client'
import { authClient } from './client'
/**
* Hook to access authentication state using better-auth
@@ -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,26 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error(
'Missing Supabase environment variables. Add NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to your .env.local file.',
)
}
/**
* 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() {
const client = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
})
return client
}
@@ -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,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
}
-216
View File
@@ -1,216 +0,0 @@
/**
* Property actions - API client for property management
* Makes HTTP requests to the Pascal monorepo backend
*/
import type { CreatePropertyParams, Property } from './types'
export type ActionResult<T = unknown> = {
success: boolean
data?: T
error?: string
message?: string
}
/**
* Get the backend API URL
*/
function getBackendURL(): string {
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL
}
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
return 'http://localhost:3000'
}
return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
}
/**
* Fetch all properties for the current user
*/
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
try {
const response = await fetch(`${getBackendURL()}/api/properties`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: error || 'Failed to fetch properties',
data: [],
}
}
const result = await response.json()
return {
success: true,
data: result.data || [],
}
} 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 response = await fetch(`${getBackendURL()}/api/properties/active`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: error || 'Failed to fetch active property',
data: null,
}
}
const result = await response.json()
return {
success: true,
data: result.data || null,
}
} 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 response = await fetch(`${getBackendURL()}/api/properties/active`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ propertyId }),
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: error || 'Failed to set active property',
}
}
const result = await response.json()
return {
success: true,
message: result.message || (propertyId ? 'Active property updated' : 'Active property cleared'),
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set active 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 response = await fetch(`${getBackendURL()}/api/properties/check-duplicate`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: error || 'Failed to check for duplicates',
}
}
const result = await response.json()
return {
success: true,
data: result.data || { isDuplicate: false },
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
}
}
}
/**
* Create a new property
*/
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
try {
const response = await fetch(`${getBackendURL()}/api/properties`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: error || 'Failed to create property',
}
}
const result = await response.json()
return {
success: true,
data: result.data,
message: result.message || 'Property created successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create property',
}
}
}
+3
View File
@@ -25,8 +25,10 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-google-maps/api": "^2.20.8",
"@react-three/uikit-lucide": "^1.0.60",
"@repo/ui": "*",
"@supabase/supabase-js": "^2.95.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/three": "^0.182.0",
"better-auth": "^1.4.18",
@@ -34,6 +36,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next": "16.1.0",
"postcss": "^8.5.6",
"react": "^19.2.0",
+41
View File
@@ -36,8 +36,10 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-google-maps/api": "^2.20.8",
"@react-three/uikit-lucide": "^1.0.60",
"@repo/ui": "*",
"@supabase/supabase-js": "^2.95.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/three": "^0.182.0",
"better-auth": "^1.4.18",
@@ -45,6 +47,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next": "16.1.0",
"postcss": "^8.5.6",
"react": "^19.2.0",
@@ -212,6 +215,10 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@googlemaps/js-api-loader": ["@googlemaps/js-api-loader@1.16.8", "", {}, "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ=="],
"@googlemaps/markerclusterer": ["@googlemaps/markerclusterer@2.5.3", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "supercluster": "^8.0.1" } }, "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -410,6 +417,12 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-google-maps/api": ["@react-google-maps/api@2.20.8", "", { "dependencies": { "@googlemaps/js-api-loader": "1.16.8", "@googlemaps/markerclusterer": "2.5.3", "@react-google-maps/infobox": "2.20.0", "@react-google-maps/marker-clusterer": "2.20.0", "@types/google.maps": "3.58.1", "invariant": "2.2.4" }, "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19", "react-dom": "^16.8 || ^17 || ^18 || ^19" } }, "sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg=="],
"@react-google-maps/infobox": ["@react-google-maps/infobox@2.20.0", "", {}, "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ=="],
"@react-google-maps/marker-clusterer": ["@react-google-maps/marker-clusterer@2.20.0", "", {}, "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw=="],
"@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="],
"@react-three/fiber": ["@react-three/fiber@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA=="],
@@ -426,6 +439,18 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@supabase/auth-js": ["@supabase/auth-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.95.3", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw=="],
"@supabase/storage-js": ["@supabase/storage-js@2.95.3", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.95.3", "", { "dependencies": { "@supabase/auth-js": "2.95.3", "@supabase/functions-js": "2.95.3", "@supabase/postgrest-js": "2.95.3", "@supabase/realtime-js": "2.95.3", "@supabase/storage-js": "2.95.3" } }, "sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@@ -464,12 +489,16 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
@@ -482,6 +511,8 @@
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
@@ -750,6 +781,8 @@
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -764,6 +797,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -842,6 +877,8 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"kdbush": ["kdbush@4.0.2", "", {}, "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
@@ -1062,6 +1099,8 @@
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1164,6 +1203,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],