hub features

This commit is contained in:
wass08
2026-02-16 13:43:19 +09:00
parent 5d17fecf94
commit 66e8453db3
22 changed files with 1869 additions and 43 deletions
@@ -0,0 +1,30 @@
'use client'
import Editor from '@/components/editor'
import { useParams } from 'next/navigation'
import { useEffect, useLayoutEffect } from 'react'
import { usePropertyStore } from '@/features/community/lib/properties/store'
import { useAuth } from '@/features/community/lib/auth/hooks'
export default function EditorPage() {
const params = useParams()
const propertyId = params.propertyId as string
const { isAuthenticated } = useAuth()
const setActiveProperty = usePropertyStore((state) => state.setActiveProperty)
// Use layoutEffect to set active property BEFORE the editor renders and hooks run
useLayoutEffect(() => {
// For authenticated users with cloud properties, set the active property from URL
if (isAuthenticated && propertyId && !propertyId.startsWith('local_')) {
setActiveProperty(propertyId)
}
}, [propertyId, isAuthenticated, setActiveProperty])
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor propertyId={propertyId} />
</div>
</div>
)
}
+2 -8
View File
@@ -1,11 +1,5 @@
import Editor from '../components/editor'
import CommunityHub from '@/features/community/components/community-hub'
export default function Home() {
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor />
</div>
</div>
)
return <CommunityHub />
}
+34 -11
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'
import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions'
export default function ViewerPage() {
const params = useParams()
@@ -16,25 +17,47 @@ export default function ViewerPage() {
const setScene = useScene((state) => state.setScene)
useEffect(() => {
const loadDemo = async () => {
const loadContent = async () => {
try {
const response = await fetch(`/demos/${id}.json`)
if (!response.ok) {
throw new Error(`Demo "${id}" not found`)
}
const data = await response.json()
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync()
// Check if it's a demo file (starts with 'demo_')
if (id.startsWith('demo_')) {
const response = await fetch(`/demos/${id}.json`)
if (!response.ok) {
throw new Error(`Demo "${id}" not found`)
}
const data = await response.json()
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync()
}
} else {
// Load from database (public property)
const result = await getPropertyModelPublic(id)
if (result.success && result.data) {
const { model } = result.data
if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph
setScene(nodes, rootNodeIds)
initSpatialGridSync()
}
// Increment view count
await incrementPropertyViews(id)
} else {
throw new Error(result.error || 'Property not found')
}
}
setLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load demo')
setError(err instanceof Error ? err.message : 'Failed to load content')
setLoading(false)
}
}
loadDemo()
loadContent()
}, [id, setScene])
if (loading) {
+20 -2
View File
@@ -5,6 +5,8 @@ import { Viewer } from '@pascal-app/viewer'
import { useKeyboard } from '@/hooks/use-keyboard'
import useEditor from '@/store/use-editor'
import { usePropertyScene } from '@/features/community/lib/models/hooks'
import { useLocalPropertyScene } from '@/features/community/lib/local-storage/hooks'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
@@ -28,8 +30,24 @@ initSpaceDetectionSync(useScene, useEditor)
// Initialize SFX bus to connect events to sound effects
initSFXBus()
export default function Editor() {
interface EditorProps {
propertyId?: string
}
export default function Editor({ propertyId }: EditorProps) {
useKeyboard()
const { isAuthenticated } = useAuth()
// Determine which mode to use
const isLocalProperty = propertyId?.startsWith('local_')
const shouldUseCloud = isAuthenticated && !isLocalProperty
const shouldUseLocal = !shouldUseCloud && !!propertyId
// Call hooks unconditionally (hooks internally check if they should activate)
// Cloud hook activates when there's an activeProperty in the store
usePropertyScene()
// Local hook activates when propertyId is provided and starts with 'local_'
useLocalPropertyScene(shouldUseLocal ? propertyId : undefined)
return (
<div className="w-full h-full">
@@ -43,7 +61,7 @@ export default function Editor() {
<PascalRadio />
</div>
<div className="pointer-events-auto">
<CloudSaveButton />
<CloudSaveButton propertyId={propertyId} />
</div>
</div>
@@ -1,23 +1,31 @@
'use client'
import { Cloud } from 'lucide-react'
import { Cloud, Home } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks'
import { usePropertyStore } from '../lib/properties/store'
import { ProfileDropdown } from './profile-dropdown'
import { PropertyDropdown } from './property-dropdown'
import { SignInDialog } from './sign-in-dialog'
interface CloudSaveButtonProps {
propertyId?: string
}
/**
* CloudSaveButton - Shows authentication state and property management
*
* Not authenticated: Shows "Save to cloud" button
* Authenticated: Shows PropertyDropdown and ProfileDropdown
* Guest with local property: Shows "Save to cloud" button
* Guest without property: Shows "Home" button
* Authenticated: Shows ProfileDropdown
*/
export function CloudSaveButton() {
export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
const { isAuthenticated, isLoading } = useAuth()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const initialize = usePropertyStore(state => state.initialize)
const router = useRouter()
const isLocalProperty = propertyId?.startsWith('local_')
// Initialize property store when authenticated
useEffect(() => {
@@ -36,7 +44,8 @@ export function CloudSaveButton() {
)
}
if (!isAuthenticated) {
// Guest user with local property
if (!isAuthenticated && isLocalProperty) {
return (
<>
<div className="pointer-events-auto">
@@ -53,12 +62,25 @@ export function CloudSaveButton() {
)
}
// Guest user (no property context or browsing)
if (!isAuthenticated) {
return (
<div className="pointer-events-auto">
<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={() => router.push('/')}
>
<Home className="h-4 w-4" />
Home
</button>
</div>
)
}
// Authenticated user
return (
<div className="pointer-events-auto">
<div className="flex items-center gap-2">
<PropertyDropdown />
<ProfileDropdown />
</div>
<ProfileDropdown />
</div>
)
}
@@ -0,0 +1,217 @@
'use client'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import { useRouter } from 'next/navigation'
import { SignInDialog } from './sign-in-dialog'
import { PropertyGrid } from './property-grid'
import { CreatePropertyButton } from './create-property-button'
import { ProfileDropdown } from './profile-dropdown'
import { NewPropertyDialog } from './new-property-dialog'
import { getPublicProperties, getUserProperties } from '../lib/properties/actions'
import { getLocalProperties, createLocalProperty } from '../lib/local-storage/property-store'
import type { Property } from '../lib/properties/types'
import type { LocalProperty } from '../lib/local-storage/property-store'
export default function CommunityHub() {
const { isAuthenticated, isLoading: authLoading, user } = useAuth()
const router = useRouter()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
const [localPropertyToSave, setLocalPropertyToSave] = useState<LocalProperty | null>(null)
const [publicProperties, setPublicProperties] = useState<Property[]>([])
const [userProperties, setUserProperties] = useState<Property[]>([])
const [localProperties, setLocalProperties] = useState<LocalProperty[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function loadProperties() {
setLoading(true)
// Load public properties (always)
const publicResult = await getPublicProperties()
if (publicResult.success) {
setPublicProperties(publicResult.data || [])
}
// Load user properties if authenticated
if (isAuthenticated) {
const userResult = await getUserProperties()
if (userResult.success) {
setUserProperties(userResult.data || [])
}
}
// Always load local properties
setLocalProperties(getLocalProperties())
setLoading(false)
}
if (!authLoading) {
loadProperties()
}
}, [isAuthenticated, authLoading])
const handleCreateProperty = async () => {
if (!isAuthenticated) {
// Create local property for guest
const property = createLocalProperty('Untitled Property')
router.push(`/editor/${property.id}`)
} else {
// Open property creation dialog for authenticated users
setIsNewPropertyDialogOpen(true)
}
}
const handlePropertyCreated = async (propertyId: string) => {
// If this was a local property being saved, delete it from localStorage
if (localPropertyToSave) {
const { deleteLocalProperty } = await import('../lib/local-storage/property-store')
deleteLocalProperty(localPropertyToSave.id)
setLocalProperties(getLocalProperties())
setLocalPropertyToSave(null)
}
// Reload properties and navigate to the new property
const result = await getUserProperties()
if (result.success) {
setUserProperties(result.data || [])
}
router.push(`/editor/${propertyId}`)
}
const handleSaveLocalToCloud = (localProperty: LocalProperty) => {
setLocalPropertyToSave(localProperty)
setIsNewPropertyDialogOpen(true)
}
const handlePropertyClick = (propertyId: string) => {
router.push(`/editor/${propertyId}`)
}
const handleViewProperty = (propertyId: string) => {
router.push(`/viewer/${propertyId}`)
}
if (authLoading || loading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Hub</h1>
{!isAuthenticated ? (
<button
onClick={() => setIsSignInDialogOpen(true)}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Sign In
</button>
) : (
<ProfileDropdown />
)}
</div>
</div>
</header>
<main className="container mx-auto px-6 py-8 space-y-12">
{/* User's Properties Section */}
{isAuthenticated && (userProperties.length > 0 || localProperties.length > 0) && (
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">My Properties</h2>
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
</div>
<PropertyGrid
properties={[...userProperties, ...localProperties]}
onPropertyClick={handlePropertyClick}
onViewClick={handleViewProperty}
onSaveToCloud={handleSaveLocalToCloud}
showOwner={false}
canEdit
onUpdate={() => {
// Reload properties after settings update
if (!authLoading) {
getUserProperties().then((result) => {
if (result.success) {
setUserProperties(result.data || [])
}
})
}
}}
/>
</section>
)}
{/* Local Properties Section (Guest Users) */}
{!isAuthenticated && localProperties.length > 0 && (
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">My Local Projects</h2>
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
</div>
<PropertyGrid
properties={localProperties}
onPropertyClick={handlePropertyClick}
showOwner={false}
isLocal
/>
</section>
)}
{/* Create First Property CTA */}
{!isAuthenticated && localProperties.length === 0 && (
<section className="text-center py-12">
<h2 className="text-2xl font-semibold mb-4">Get Started</h2>
<p className="text-muted-foreground mb-6">
Create your first property to start designing
</p>
<CreatePropertyButton onCreateProperty={handleCreateProperty} />
</section>
)}
{/* Public Properties Section */}
<section>
<h2 className="text-xl font-semibold mb-6">Community Properties</h2>
{publicProperties.length > 0 ? (
<PropertyGrid
properties={publicProperties}
onPropertyClick={handleViewProperty}
showOwner
/>
) : (
<div className="text-center py-12 text-muted-foreground">
No public properties yet
</div>
)}
</section>
</main>
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
<NewPropertyDialog
open={isNewPropertyDialogOpen}
onOpenChange={setIsNewPropertyDialogOpen}
onSuccess={handlePropertyCreated}
localPropertyData={
localPropertyToSave
? {
id: localPropertyToSave.id,
name: localPropertyToSave.name,
sceneGraph: localPropertyToSave.scene_graph,
}
: undefined
}
/>
</div>
)
}
@@ -0,0 +1,19 @@
'use client'
import { Plus } from 'lucide-react'
interface CreatePropertyButtonProps {
onCreateProperty: () => void
}
export function CreatePropertyButton({ onCreateProperty }: CreatePropertyButtonProps) {
return (
<button
onClick={onCreateProperty}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Create Property</span>
</button>
)
}
@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import type { LocalProperty } from '../lib/local-storage/property-store'
interface LocalPropertyMigrationDialogProps {
localProperties: LocalProperty[]
open: boolean
onMigrate: () => Promise<void>
onSkip: () => void
}
export function LocalPropertyMigrationDialog({
localProperties,
open,
onMigrate,
onSkip,
}: LocalPropertyMigrationDialogProps) {
const [isMigrating, setIsMigrating] = useState(false)
const handleMigrate = async () => {
setIsMigrating(true)
try {
await onMigrate()
} finally {
setIsMigrating(false)
}
}
return (
<Dialog open={open} onOpenChange={(open) => !open && !isMigrating && onSkip()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Save Local Properties to Cloud</DialogTitle>
<DialogDescription>
You have {localProperties.length} local {localProperties.length === 1 ? 'property' : 'properties'} that {localProperties.length === 1 ? 'hasn\'t' : 'haven\'t'} been saved to the cloud yet.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-4">
<p className="text-sm text-muted-foreground">
Would you like to save {localProperties.length === 1 ? 'it' : 'them'} to your account?
</p>
<ul className="space-y-1 text-sm">
{localProperties.map((property) => (
<li key={property.id} className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{property.name}</span>
</li>
))}
</ul>
</div>
<DialogFooter>
<button
type="button"
onClick={onSkip}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={isMigrating}
>
Skip for now
</button>
<button
type="button"
onClick={handleMigrate}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={isMigrating}
>
{isMigrating ? 'Saving...' : 'Save to Cloud'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -4,12 +4,18 @@ 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 { Switch } from '@/components/ui/primitives/switch'
import { GoogleAddressSearch } from './google-address-search'
interface NewPropertyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: (propertyId: string) => void
localPropertyData?: {
id: string
name: string
sceneGraph: any
}
}
interface AddressData {
@@ -26,8 +32,9 @@ interface AddressData {
/**
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
*/
export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
export function NewPropertyDialog({ open, onOpenChange, onSuccess, localPropertyData }: NewPropertyDialogProps) {
const [address, setAddress] = useState<AddressData | null>(null)
const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -58,11 +65,14 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
state: address.state,
postalCode: address.postalCode,
country: address.country || 'US',
isPrivate,
sceneGraph: localPropertyData?.sceneGraph,
})
if (result.success && result.data) {
onOpenChange(false)
setAddress(null)
setIsPrivate(false)
onSuccess?.(result.data.id)
} else {
setError(result.error || 'Failed to create property')
@@ -78,6 +88,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
if (!isCreating) {
onOpenChange(false)
setAddress(null)
setIsPrivate(false)
setError(null)
}
}
@@ -112,6 +123,31 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</div>
)}
{/* Privacy Toggle */}
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<div className="font-medium text-sm">Privacy</div>
<div className="text-xs text-muted-foreground">
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{localPropertyData && (
<div className="rounded-md border border-blue-500/50 bg-blue-500/10 p-3 text-sm">
<p className="font-medium text-blue-700 dark:text-blue-300">
Saving local property: {localPropertyData.name}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Your building data will be preserved
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
@@ -0,0 +1,234 @@
'use client'
import { Eye, Heart, Settings } from 'lucide-react'
import { useEffect, useState } from 'react'
import type { Property } from '../lib/properties/types'
import type { LocalProperty } from '../lib/local-storage/property-store'
import { PropertySettingsDialog } from './property-settings-dialog'
import { getUserPropertyLikes, togglePropertyLike } from '../lib/properties/actions'
import { useAuth } from '../lib/auth/hooks'
interface PropertyGridProps {
properties: (Property | LocalProperty)[]
onPropertyClick: (id: string) => void
onViewClick?: (id: string) => void
onSaveToCloud?: (property: LocalProperty) => void
showOwner: boolean
isLocal?: boolean
canEdit?: boolean
onUpdate?: () => void
}
function isLocalProperty(prop: Property | LocalProperty): prop is LocalProperty {
return 'is_local' in prop && prop.is_local === true
}
export function PropertyGrid({
properties,
onPropertyClick,
onViewClick,
onSaveToCloud,
showOwner,
isLocal = false,
canEdit = false,
onUpdate,
}: PropertyGridProps) {
const { isAuthenticated } = useAuth()
const [settingsProperty, setSettingsProperty] = useState<Property | null>(null)
const [userLikes, setUserLikes] = useState<Record<string, boolean>>({})
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({})
// Initialize like counts from properties
useEffect(() => {
const counts: Record<string, number> = {}
properties.forEach((prop) => {
if (!isLocalProperty(prop)) {
counts[prop.id] = prop.likes
}
})
setLikeCounts(counts)
}, [properties])
// Fetch which properties the user has liked
useEffect(() => {
if (!isAuthenticated) {
setUserLikes({})
return
}
const propertyIds = properties
.filter((p) => !isLocalProperty(p))
.map((p) => p.id)
if (propertyIds.length === 0) return
getUserPropertyLikes(propertyIds).then((result) => {
if (result.success && result.data) {
setUserLikes(result.data)
}
})
}, [properties, isAuthenticated])
const handleSettingsClick = (e: React.MouseEvent, property: Property | LocalProperty) => {
e.stopPropagation()
if (!isLocalProperty(property)) {
setSettingsProperty(property)
}
}
const handleViewClick = (e: React.MouseEvent, propertyId: string) => {
e.stopPropagation()
onViewClick?.(propertyId)
}
const handleLikeClick = async (e: React.MouseEvent, propertyId: string) => {
e.stopPropagation()
if (!isAuthenticated) {
// Could show a sign-in prompt here
return
}
// Optimistic update
const wasLiked = userLikes[propertyId] || false
const currentCount = likeCounts[propertyId] || 0
setUserLikes((prev) => ({ ...prev, [propertyId]: !wasLiked }))
setLikeCounts((prev) => ({
...prev,
[propertyId]: wasLiked ? currentCount - 1 : currentCount + 1
}))
// Call server action
const result = await togglePropertyLike(propertyId)
if (result.success && result.data) {
// Update with actual values from server
setUserLikes((prev) => ({ ...prev, [propertyId]: result.data.liked }))
setLikeCounts((prev) => ({ ...prev, [propertyId]: result.data.likes }))
} else {
// Revert on error
setUserLikes((prev) => ({ ...prev, [propertyId]: wasLiked }))
setLikeCounts((prev) => ({ ...prev, [propertyId]: currentCount }))
}
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{properties.map((property) => (
<button
key={property.id}
onClick={() => onPropertyClick(property.id)}
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left"
>
{/* Thumbnail */}
<div className="aspect-video bg-muted relative">
{!isLocalProperty(property) && property.thumbnail_url ? (
<img
src={property.thumbnail_url}
alt={property.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
No preview
</div>
)}
{isLocalProperty(property) && (
<div className="absolute top-2 right-2">
{isAuthenticated && onSaveToCloud ? (
<button
onClick={(e) => {
e.stopPropagation()
onSaveToCloud(property)
}}
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
title="Save to cloud"
>
Save to cloud
</button>
) : (
<div className="bg-blue-500 text-white text-xs px-2 py-1 rounded">
Local
</div>
)}
</div>
)}
{canEdit && !isLocalProperty(property) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onViewClick && (
<button
onClick={(e) => handleViewClick(e, property.id)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="View"
title="View in viewer mode"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={(e) => handleSettingsClick(e, property)}
className="bg-background/80 hover:bg-background rounded-md p-1.5"
aria-label="Settings"
title="Property settings"
>
<Settings className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Info */}
<div className="p-4">
<h3 className="font-medium text-left line-clamp-2 mb-2">{property.name}</h3>
{!isLocalProperty(property) && (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="w-4 h-4" />
<span>{property.views}</span>
</div>
<button
onClick={(e) => handleLikeClick(e, property.id)}
className="flex items-center gap-1 hover:text-red-500 transition-colors"
disabled={!isAuthenticated}
>
<Heart
className={`w-4 h-4 ${
userLikes[property.id]
? 'fill-red-500 text-red-500'
: ''
}`}
/>
<span>{likeCounts[property.id] ?? property.likes}</span>
</button>
</div>
)}
{isLocalProperty(property) && (
<div className="text-sm text-muted-foreground">
{new Date(property.updated_at).toLocaleDateString()}
</div>
)}
</div>
</button>
))}
</div>
{/* Settings Dialog */}
{settingsProperty && (
<PropertySettingsDialog
property={settingsProperty}
open={!!settingsProperty}
onOpenChange={(open) => !open && setSettingsProperty(null)}
onUpdate={onUpdate}
onDelete={() => {
setSettingsProperty(null)
onUpdate?.()
}}
/>
)}
</>
)
}
@@ -0,0 +1,242 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { updatePropertyAddress, updatePropertyPrivacy, deleteProperty } from '../lib/properties/actions'
import type { Property } from '../lib/properties/types'
interface PropertySettingsDialogProps {
property: Property
open: boolean
onOpenChange: (open: boolean) => void
onUpdate?: () => void
onDelete?: () => void
}
export function PropertySettingsDialog({
property,
open,
onOpenChange,
onUpdate,
onDelete,
}: PropertySettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isPrivate, setIsPrivate] = useState(property.is_private)
const [address, setAddress] = useState({
street_number: property.address.street_number || '',
route: property.address.route || '',
city: property.address.city || '',
state: property.address.state || '',
postal_code: property.address.postal_code || '',
country: property.address.country || 'US',
})
const handleSave = async () => {
setLoading(true)
try {
// Update privacy if changed
if (isPrivate !== property.is_private) {
const privacyResult = await updatePropertyPrivacy(property.id, isPrivate)
if (!privacyResult.success) {
alert(`Failed to update privacy: ${privacyResult.error}`)
setLoading(false)
return
}
}
// Update address if changed
const addressChanged =
address.street_number !== (property.address.street_number || '') ||
address.route !== (property.address.route || '') ||
address.city !== (property.address.city || '') ||
address.state !== (property.address.state || '') ||
address.postal_code !== (property.address.postal_code || '') ||
address.country !== (property.address.country || 'US')
if (addressChanged) {
const addressResult = await updatePropertyAddress(property.id, address)
if (!addressResult.success) {
alert(`Failed to update address: ${addressResult.error}`)
setLoading(false)
return
}
}
onUpdate?.()
onOpenChange(false)
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const result = await deleteProperty(property.id)
if (result.success) {
onDelete?.()
onOpenChange(false)
} else {
alert(`Failed to delete property: ${result.error}`)
}
} catch (error) {
alert('Failed to delete property')
} finally {
setIsDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Property Settings</DialogTitle>
<DialogDescription>Update property address and privacy settings</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Privacy Toggle */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Privacy</div>
<div className="text-sm text-muted-foreground">
{isPrivate ? 'Only you can view this property' : 'Anyone can view this property'}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} />
</div>
</div>
{/* Address Fields */}
<div className="space-y-4">
<h3 className="font-medium">Address</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Street Number</label>
<input
type="text"
value={address.street_number}
onChange={(e) => setAddress({ ...address, street_number: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="123"
/>
</div>
<div>
<label className="text-sm font-medium">Street</label>
<input
type="text"
value={address.route}
onChange={(e) => setAddress({ ...address, route: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="Main St"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">City</label>
<input
type="text"
value={address.city}
onChange={(e) => setAddress({ ...address, city: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="San Francisco"
/>
</div>
<div>
<label className="text-sm font-medium">State</label>
<input
type="text"
value={address.state}
onChange={(e) => setAddress({ ...address, state: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="CA"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Postal Code</label>
<input
type="text"
value={address.postal_code}
onChange={(e) => setAddress({ ...address, postal_code: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="94102"
/>
</div>
<div>
<label className="text-sm font-medium">Country</label>
<input
type="text"
value={address.country}
onChange={(e) => setAddress({ ...address, country: e.target.value })}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder="US"
/>
</div>
</div>
</div>
{/* Danger Zone */}
<div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-3">
Once you delete a property, there is no going back. Please be certain.
</p>
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading}
>
{isDeleting ? 'Deleting...' : 'Delete Property'}
</button>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,93 @@
'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import useEditor from '@/store/use-editor'
import { getLocalProperty, updateLocalPropertyScene } from './property-store'
/**
* Hook for local property scene management (guest users)
* Loads scene from localStorage and auto-saves changes
*/
export function useLocalPropertyScene(propertyId?: string) {
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const currentPropertyIdRef = useRef<string | null>(null)
const lastPropertyIdRef = useRef<string | null>(null)
// Load scene when property ID changes
useEffect(() => {
if (!propertyId || !propertyId.startsWith('local_')) {
return
}
if (lastPropertyIdRef.current === propertyId) {
return
}
lastPropertyIdRef.current = propertyId
currentPropertyIdRef.current = propertyId
const property = getLocalProperty(propertyId)
if (property?.scene_graph) {
const { nodes, rootNodeIds } = property.scene_graph
useScene.getState().setScene(nodes, rootNodeIds)
initSpatialGridSync()
} else {
useScene.getState().clearScene()
}
useEditor.getState().setPhase('site')
useViewer.getState().setSelection({
buildingId: null,
levelId: null,
selectedIds: [],
zoneId: null,
})
}, [propertyId])
// Auto-save to localStorage with debouncing
useEffect(() => {
if (!propertyId || !propertyId.startsWith('local_')) {
currentPropertyIdRef.current = null
return
}
currentPropertyIdRef.current = propertyId
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
const unsubscribe = useScene.subscribe((state) => {
const currentNodesSnapshot = JSON.stringify(state.nodes)
if (currentNodesSnapshot === lastNodesSnapshot) {
return
}
lastNodesSnapshot = currentNodesSnapshot
const nodes = state.nodes
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Debounce save by 1 second (faster than cloud save)
saveTimeoutRef.current = setTimeout(() => {
const currentId = currentPropertyIdRef.current
if (!currentId) return
const rootNodeIds = useScene.getState().rootNodeIds
const sceneGraph = { nodes, rootNodeIds }
updateLocalPropertyScene(currentId, sceneGraph)
}, 1000)
})
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
unsubscribe()
}
}, [propertyId])
}
@@ -0,0 +1,89 @@
/**
* Local storage management for guest users
* Stores properties and scenes in browser localStorage
*/
import { createId } from '../utils/id-generator'
export interface SceneGraph {
nodes: Record<string, any>
rootNodeIds: string[]
}
export interface LocalProperty {
id: string // Format: 'local_property_xyz'
name: string
created_at: string
updated_at: string
scene_graph: SceneGraph | null
is_local: true
}
const LOCAL_PROPERTIES_KEY = 'pascal_local_properties'
export function getLocalProperties(): LocalProperty[] {
if (typeof window === 'undefined') return []
try {
const stored = localStorage.getItem(LOCAL_PROPERTIES_KEY)
return stored ? JSON.parse(stored) : []
} catch (error) {
console.error('Failed to load local properties:', error)
return []
}
}
export function getLocalProperty(id: string): LocalProperty | null {
const properties = getLocalProperties()
return properties.find((p) => p.id === id) || null
}
export function saveLocalProperty(property: LocalProperty): void {
const properties = getLocalProperties()
const index = properties.findIndex((p) => p.id === property.id)
if (index >= 0) {
properties[index] = { ...property, updated_at: new Date().toISOString() }
} else {
properties.push(property)
}
localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties))
}
export function createLocalProperty(name: string): LocalProperty {
const property: LocalProperty = {
id: createId('local_property'),
name,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
scene_graph: null,
is_local: true,
}
saveLocalProperty(property)
return property
}
export function deleteLocalProperty(id: string): void {
const properties = getLocalProperties().filter((p) => p.id !== id)
localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties))
}
export function updateLocalPropertyScene(id: string, sceneGraph: SceneGraph): void {
const property = getLocalProperty(id)
if (property) {
property.scene_graph = sceneGraph
saveLocalProperty(property)
}
}
export function migrateLocalPropertiesToCloud(userId: string): LocalProperty[] {
// Return local properties that need to be migrated
// Actual migration handled by separate function
return getLocalProperties()
}
export function clearLocalProperties(): void {
localStorage.removeItem(LOCAL_PROPERTIES_KEY)
}
@@ -42,6 +42,7 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
address:properties_addresses(*)
`)
.eq('owner_id', session.user.id)
.order('created_at', { ascending: false })
if (error) {
return {
@@ -218,6 +219,7 @@ export async function createProperty(params: CreatePropertyParams): Promise<Acti
name: params.name,
address_id: address.id,
owner_id: session.user.id,
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
details_json: {
coordinates: params.center,
createdFrom: 'editor-app',
@@ -239,6 +241,22 @@ export async function createProperty(params: CreatePropertyParams): Promise<Acti
}
}
// If scene graph is provided, create the model
if (params.sceneGraph) {
const modelId = createId('model')
const { error: modelError } = await supabase.from('properties_models').insert({
id: modelId,
property_id: propertyId,
version: 1,
scene_graph: params.sceneGraph,
})
if (modelError) {
console.error('Failed to create model:', modelError)
// Don't fail the property creation if model creation fails
}
}
return {
success: true,
data: data as Property,
@@ -326,3 +344,549 @@ export async function checkPropertyDuplicate(params: {
}
}
}
/**
* Fetch public properties for community hub
*/
export async function getPublicProperties(): Promise<ActionResult<Property[]>> {
try {
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase
.from('properties')
.select(`
*,
address:properties_addresses(*)
`)
.eq('is_private', false)
.order('views', { ascending: false })
.limit(50)
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 public properties',
data: [],
}
}
}
/**
* Get a property model for viewing
* Allows viewing if: property is public OR user owns the property
*/
export async function getPropertyModelPublic(propertyId: string): Promise<
ActionResult<{ property: Property; model: any | null }>
> {
try {
const session = await getSession()
const supabase = await createServerSupabaseClient()
// Get the property (without privacy filter first)
const { data: property, error: propertyError } = await supabase
.from('properties')
.select(`
*,
address:properties_addresses(*)
`)
.eq('id', propertyId)
.single()
if (propertyError || !property) {
return {
success: false,
error: 'Property not found',
data: null,
}
}
// Check if user can view this property
// Allow if: property is public OR user owns it
const isOwner = session?.user && property.owner_id === session.user.id
const isPublic = property.is_private === false
if (!isPublic && !isOwner) {
return {
success: false,
error: 'Property is private',
data: null,
}
}
// Get the model
const { data: model } = await supabase
.from('properties_models')
.select('*')
.eq('property_id', propertyId)
.is('deleted_at', null)
.order('version', { ascending: false })
.limit(1)
.maybeSingle()
return {
success: true,
data: {
property: property as Property,
model: model || null,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch property',
data: null,
}
}
}
/**
* Increment property view count
*/
export async function incrementPropertyViews(propertyId: string): Promise<ActionResult> {
try {
const supabase = await createServerSupabaseClient()
const { error } = await supabase.rpc('increment_property_views', {
property_id: propertyId,
})
if (error) {
console.error('Failed to increment views:', error)
// Don't fail the request if view increment fails
}
return { success: true }
} catch (error) {
console.error('Failed to increment views:', error)
return { success: true } // Don't fail on view tracking errors
}
}
/**
* Update property privacy setting
*/
export async function updatePropertyPrivacy(
propertyId: string,
isPrivate: boolean,
): Promise<ActionResult> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: property } = await supabase
.from('properties')
.select('owner_id')
.eq('id', propertyId)
.single()
if (property?.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// Update privacy
const { error } = await supabase
.from('properties')
.update({ is_private: isPrivate })
.eq('id', propertyId)
if (error) {
return {
success: false,
error: error.message,
}
}
return {
success: true,
message: `Property is now ${isPrivate ? 'private' : 'public'}`,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update property privacy',
}
}
}
/**
* Update property address
*/
export async function updatePropertyAddress(
propertyId: string,
addressData: {
street_number?: string
route?: string
city?: string
state?: string
postal_code?: string
country?: string
},
): Promise<ActionResult> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Verify ownership and get address_id
const { data: property } = await supabase
.from('properties')
.select('owner_id, address_id')
.eq('id', propertyId)
.single()
if (!property) {
return {
success: false,
error: 'Property not found',
}
}
if (property.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// Update address
const { error } = await supabase
.from('properties_addresses')
.update(addressData)
.eq('id', property.address_id)
if (error) {
return {
success: false,
error: error.message,
}
}
return {
success: true,
message: 'Address updated successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update address',
}
}
}
/**
* Migrate a local property to the cloud
* Creates a new property with the local property's data
*/
export async function migrateLocalProperty(
localProperty: {
name: string
scene_graph: any
},
): Promise<ActionResult<{ id: string }>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Create a default address (user can edit later via settings)
const addressId = createId('address')
const { error: addressError } = await supabase.from('properties_addresses').insert({
id: addressId,
country: 'US',
})
if (addressError) {
return {
success: false,
error: addressError.message,
}
}
// Create the property
const propertyId = createId('property')
const { error: propertyError } = await supabase.from('properties').insert({
id: propertyId,
name: localProperty.name,
owner_id: session.user.id,
address_id: addressId,
is_private: true, // Default to private
})
if (propertyError) {
return {
success: false,
error: propertyError.message,
}
}
// Create the model with the scene graph
if (localProperty.scene_graph) {
const modelId = createId('model')
const { error: modelError } = await supabase.from('properties_models').insert({
id: modelId,
property_id: propertyId,
version: 1,
scene_graph: localProperty.scene_graph,
})
if (modelError) {
return {
success: false,
error: modelError.message,
}
}
}
return {
success: true,
data: { id: propertyId },
message: 'Property migrated successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to migrate property',
}
}
}
/**
* Delete a property
* Only the owner can delete their property
*/
export async function deleteProperty(propertyId: string): Promise<ActionResult> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: property } = await supabase
.from('properties')
.select('owner_id')
.eq('id', propertyId)
.single()
if (!property) {
return {
success: false,
error: 'Property not found',
}
}
if (property.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// Delete the property (cascade will delete related records)
const { error } = await supabase.from('properties').delete().eq('id', propertyId)
if (error) {
return {
success: false,
error: error.message,
}
}
return {
success: true,
message: 'Property deleted successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete property',
}
}
}
/**
* Check if the current user has liked specific properties
* Returns a map of propertyId -> boolean
*/
export async function getUserPropertyLikes(
propertyIds: string[],
): Promise<ActionResult<Record<string, boolean>>> {
try {
const session = await getSession()
if (!session?.user || propertyIds.length === 0) {
// Return empty map for unauthenticated users or no properties
return {
success: true,
data: {},
}
}
const supabase = await createServerSupabaseClient()
const { data: likes, error } = await supabase
.from('property_likes')
.select('property_id')
.eq('user_id', session.user.id)
.in('property_id', propertyIds)
if (error) {
return {
success: false,
error: error.message,
data: {},
}
}
// Convert array to map
const likeMap: Record<string, boolean> = {}
propertyIds.forEach((id) => {
likeMap[id] = likes?.some((like) => like.property_id === id) || false
})
return {
success: true,
data: likeMap,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch likes',
data: {},
}
}
}
/**
* Toggle like on a property
* Returns the new like state and updated like count
*/
export async function togglePropertyLike(
propertyId: string,
): Promise<ActionResult<{ liked: boolean; likes: number }>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
const userId = session.user.id
// Check if user has already liked this property
const { data: existingLike } = await supabase
.from('property_likes')
.select('id')
.eq('property_id', propertyId)
.eq('user_id', userId)
.maybeSingle()
let liked = false
if (existingLike) {
// Unlike - remove the like
const { error } = await supabase
.from('property_likes')
.delete()
.eq('id', existingLike.id)
if (error) {
return {
success: false,
error: error.message,
}
}
liked = false
} else {
// Like - add a new like
const likeId = createId('like')
const { error } = await supabase.from('property_likes').insert({
id: likeId,
property_id: propertyId,
user_id: userId,
})
if (error) {
return {
success: false,
error: error.message,
}
}
liked = true
}
// Get updated like count
const { data: likeCount } = await supabase.rpc('get_property_like_count', {
property_id: propertyId,
})
// Update the property's like count cache
await supabase
.from('properties')
.update({ likes: likeCount || 0 })
.eq('id', propertyId)
return {
success: true,
data: {
liked,
likes: likeCount || 0,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to toggle like',
}
}
}
@@ -54,17 +54,8 @@ export const usePropertyStore = create<PropertyStore>((set, get) => ({
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)
}
}
}
// Note: Auto-select logic removed - now using URL-based routing
// The URL parameter determines which property to load
} else {
set({
error: result.error || 'Failed to fetch active property',
@@ -11,6 +11,11 @@ export type Property = {
address_id: string
created_at: string
updated_at: string
// Community features
is_private: boolean
views: number
likes: number
thumbnail_url: string | null
address: {
id: string
street_number?: string
@@ -40,4 +45,6 @@ export type CreatePropertyParams = {
country?: string
countryLong?: string
rawJson?: Record<string, unknown>
isPrivate?: boolean
sceneGraph?: any
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Navigation helpers for property-based routing
*/
export function getEditorUrl(propertyId: string): string {
return `/editor/${propertyId}`
}
export function getViewerUrl(propertyId: string): string {
return `/viewer/${propertyId}`
}
export function getHomeUrl(): string {
return '/'
}