Merge pull request #96 from pascalorg/feat/fixes-placements-and-tools

Feat/fixes placements and tools
This commit is contained in:
Wassim SAMAD
2026-02-16 14:44:05 +09:00
committed by GitHub
42 changed files with 2546 additions and 118 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() { export default function Home() {
return ( return <CommunityHub />
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor />
</div>
</div>
)
} }
+26 -3
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'
import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay' import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system' import { ViewerZoneSystem } from './viewer-zone-system'
import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions'
export default function ViewerPage() { export default function ViewerPage() {
const params = useParams() const params = useParams()
@@ -16,8 +17,10 @@ export default function ViewerPage() {
const setScene = useScene((state) => state.setScene) const setScene = useScene((state) => state.setScene)
useEffect(() => { useEffect(() => {
const loadDemo = async () => { const loadContent = async () => {
try { try {
// Check if it's a demo file (starts with 'demo_')
if (id.startsWith('demo_')) {
const response = await fetch(`/demos/${id}.json`) const response = await fetch(`/demos/${id}.json`)
if (!response.ok) { if (!response.ok) {
throw new Error(`Demo "${id}" not found`) throw new Error(`Demo "${id}" not found`)
@@ -27,14 +30,34 @@ export default function ViewerPage() {
setScene(data.nodes, data.rootNodeIds) setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync() 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) setLoading(false)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load demo') setError(err instanceof Error ? err.message : 'Failed to load content')
setLoading(false) setLoading(false)
} }
} }
loadDemo() loadContent()
}, [id, setScene]) }, [id, setScene])
if (loading) { if (loading) {
+19 -5
View File
@@ -5,10 +5,11 @@ import { Viewer } from '@pascal-app/viewer'
import { useKeyboard } from '@/hooks/use-keyboard' import { useKeyboard } from '@/hooks/use-keyboard'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { usePropertyScene } from '@/features/community/lib/models/hooks' 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 { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager' import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu' import { ActionMenu } from '../ui/action-menu'
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
import { PascalRadio } from '../pascal-radio' import { PascalRadio } from '../pascal-radio'
import { PanelManager } from '../ui/panels/panel-manager' import { PanelManager } from '../ui/panels/panel-manager'
import { HelperManager } from '../ui/helpers/helper-manager' import { HelperManager } from '../ui/helpers/helper-manager'
@@ -28,8 +29,24 @@ initSpaceDetectionSync(useScene, useEditor)
// Initialize SFX bus to connect events to sound effects // Initialize SFX bus to connect events to sound effects
initSFXBus() initSFXBus()
export default function Editor() { interface EditorProps {
propertyId?: string
}
export default function Editor({ propertyId }: EditorProps) {
useKeyboard() 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 ( return (
<div className="w-full h-full"> <div className="w-full h-full">
@@ -42,9 +59,6 @@ export default function Editor() {
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PascalRadio /> <PascalRadio />
</div> </div>
<div className="pointer-events-auto">
<CloudSaveButton />
</div>
</div> </div>
<SidebarProvider className="fixed z-20"> <SidebarProvider className="fixed z-20">
+2 -2
View File
@@ -108,7 +108,7 @@ export function PascalRadio() {
soundRef.current?.unload() soundRef.current?.unload()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleNext, currentTrack.file]) }, [handleNext, currentTrack.file, muted, isPlaying, effectiveVolume])
// Update volume when settings change // Update volume when settings change
useEffect(() => { useEffect(() => {
@@ -163,7 +163,7 @@ export function PascalRadio() {
return ( return (
<div 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"> <div 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">
<Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} /> <Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} />
<span className="hidden sm:inline">Pascal Radio</span> <span className="hidden sm:inline">Radio Pascal</span>
<div <div
onClick={handlePlayPause} onClick={handlePlayPause}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm" className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
@@ -0,0 +1,47 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingHoleEditorProps {
ceilingId: CeilingNode['id']
holeIndex: number
}
/**
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const holes = ceiling?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(ceilingId, { holes: updatedHoles })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, holeIndex, holes, updateNode, setSelection],
)
if (!ceiling || !hole || hole.length < 3) return null
return (
<PolygonEditor
polygon={hole}
color="#ef4444" // red for holes
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -91,12 +91,14 @@ export const wallStrategy = {
/** /**
* Handle wall:enter — transition from floor to wall surface. * Handle wall:enter — transition from floor to wall surface.
* Returns null if item doesn't attach to walls, face is invalid, or wrong level. * Returns null if item doesn't attach to walls, face is invalid, or wrong level.
* Auto-adjusts Y position to fit within wall bounds.
*/ */
enter( enter(
ctx: PlacementContext, ctx: PlacementContext,
event: WallEvent, event: WallEvent,
resolveLevelId: LevelResolver, resolveLevelId: LevelResolver,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
validators: SpatialValidators,
): TransitionResult | null { ): TransitionResult | null {
const attachTo = ctx.asset.attachTo const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
@@ -114,16 +116,30 @@ export const wallStrategy = {
const y = snapToHalf(event.localPosition[1]) const y = snapToHalf(event.localPosition[1])
const z = snapToHalf(event.localPosition[2]) const z = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
x,
y,
ctx.asset.dimensions ?? DEFAULT_DIMENSIONS,
attachTo,
side,
[],
)
const adjustedY = validation.adjustedY ?? y
return { return {
stateUpdate: { surface: 'wall', wallId: event.node.id }, stateUpdate: { surface: 'wall', wallId: event.node.id },
nodeUpdate: { nodeUpdate: {
position: [x, y, z], position: [x, adjustedY, z],
parentId: event.node.id, parentId: event.node.id,
side, side,
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
}, },
cursorRotationY: cursorRotation, cursorRotationY: cursorRotation,
gridPosition: [x, y, z], gridPosition: [x, adjustedY, z],
cursorPosition: [ cursorPosition: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
@@ -136,22 +152,37 @@ export const wallStrategy = {
/** /**
* Handle wall:move — update position while on wall. * Handle wall:move — update position while on wall.
* Returns null if not on a wall or face is invalid. * Returns null if not on a wall or face is invalid.
* Auto-adjusts Y position to fit within wall bounds.
*/ */
move(ctx: PlacementContext, event: WallEvent): PlacementResult | null { move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null {
if (ctx.state.surface !== 'wall') return null if (ctx.state.surface !== 'wall') return null
if (!ctx.draftItem) return null if (!ctx.draftItem || !ctx.levelId) return null
if (!isValidWallSideFace(event.normal)) return null if (!isValidWallSideFace(event.normal)) return null
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const snappedX = snapToHalf(event.localPosition[0])
const snappedY = snapToHalf(event.localPosition[1])
const snappedZ = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
snappedX,
snappedY,
ctx.draftItem.asset.dimensions,
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
side,
[ctx.draftItem.id],
)
const adjustedY = validation.adjustedY ?? snappedY
return { return {
gridPosition: [ gridPosition: [snappedX, adjustedY, snappedZ],
snapToHalf(event.localPosition[0]),
snapToHalf(event.localPosition[1]),
snapToHalf(event.localPosition[2]),
],
cursorPosition: [ cursorPosition: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
@@ -159,6 +190,7 @@ export const wallStrategy = {
], ],
cursorRotationY: cursorRotation, cursorRotationY: cursorRotation,
nodeUpdate: { nodeUpdate: {
position: [snappedX, adjustedY, snappedZ],
side, side,
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
}, },
@@ -93,7 +93,7 @@ export interface SpatialValidators {
attachType: 'wall' | 'wall-side', attachType: 'wall' | 'wall-side',
side?: 'front' | 'back', side?: 'front' | 'back',
ignoreIds?: string[], ignoreIds?: string[],
) => { valid: boolean } ) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }
canPlaceOnCeiling: ( canPlaceOnCeiling: (
ceilingId: CeilingNode['id'], ceilingId: CeilingNode['id'],
position: [number, number, number], position: [number, number, number],
@@ -213,7 +213,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -235,7 +235,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (ctx.state.surface !== 'wall') { if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes) const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, validators)
if (!enterResult) return if (!enterResult) return
event.stopPropagation() event.stopPropagation()
@@ -251,7 +251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) { if (!draftNode.current) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
if (!setup) return if (!setup) return
event.stopPropagation() event.stopPropagation()
@@ -259,7 +259,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
const result = wallStrategy.move(ctx, event) const result = wallStrategy.move(ctx, event, validators)
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -269,6 +269,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY cursorGroupRef.current.rotation.y = result.cursorRotationY
@@ -318,7 +323,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
@@ -393,6 +398,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
// Play snap sound when grid position changes
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
+18 -8
View File
@@ -2,6 +2,7 @@ import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pasc
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '@/store/use-editor' import useEditor, { type Phase, type Tool } from '@/store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool' import { CeilingTool } from './ceiling/ceiling-tool'
import { ItemTool } from './item/item-tool' import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
@@ -36,7 +37,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const editingSlabHoleIndex = useEditor((state) => state.editingSlabHoleIndex) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
@@ -56,15 +57,21 @@ export const ToolManager: React.FC = () => {
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole) // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor = const showSlabBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && editingSlabHoleIndex === null phase === 'structure' && mode === 'select' && selectedSlabId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedSlabId)
// Show slab hole editor when editing a specific hole // Show slab hole editor when editing a hole on the selected slab
const showSlabHoleEditor = const showSlabHoleEditor =
selectedSlabId !== undefined && editingSlabHoleIndex !== null selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
// Show ceiling boundary editor when in structure/select mode with a ceiling selected // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
const showCeilingBoundaryEditor = const showCeilingBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedCeilingId)
// Show ceiling hole editor when editing a hole on the selected ceiling
const showCeilingHoleEditor =
selectedCeilingId !== undefined && editingHole !== null && editingHole.nodeId === selectedCeilingId
// Show zone boundary editor when in structure/select mode with a zone selected // Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab or ceiling to avoid overlapping handles // Hide when editing a slab or ceiling to avoid overlapping handles
@@ -85,12 +92,15 @@ export const ToolManager: React.FC = () => {
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />} {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />} {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && ( {showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingSlabHoleIndex} /> <SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
)} )}
{showCeilingBoundaryEditor && selectedCeilingId && ( {showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} /> <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)} )}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingNode && <MoveTool />} {movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</> </>
@@ -0,0 +1,286 @@
'use client'
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useEffect } from 'react'
import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a ceiling
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
// Clear hole editing state when ceiling is deselected
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
// Clear hole editing state on unmount
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
// Calculate centroid of the ceiling polygon
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
// Create a default small rectangular hole centered at the ceiling's centroid
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
// Only show if exactly one ceiling is selected
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
// Calculate approximate area from polygon
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]![0] * polygon[j]![1]
area -= polygon[j]![0] * polygon[i]![1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || `Ceiling (${area.toFixed(1)}m²)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Height */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Height
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Height"
value={Math.round(node.height * 1000) / 1000}
onChange={(value) => {
handleUpdate({ height: value })
}}
precision={3}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<p className="text-xs text-muted-foreground">
Height from the floor where the ceiling is positioned
</p>
</div>
{/* Quick preset buttons */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Presets
</label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => handleUpdate({ height: 2.4 })}
>
Low (2.4m)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => handleUpdate({ height: 2.5 })}
>
Standard (2.5m)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => handleUpdate({ height: 3.0 })}
>
High (3m)
</button>
</div>
</div>
{/* Area info */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Area
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
{area.toFixed(2)} m²
</div>
</div>
{/* Holes */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Holes
</label>
{editingHole?.nodeId === selectedId ? (
<button
type="button"
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
onClick={() => setEditingHole(null)}
>
<span>Done Editing</span>
</button>
) : (
<button
type="button"
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
onClick={handleAddHole}
>
<Plus className="h-3 w-3" />
<span>Add Hole</span>
</button>
)}
</div>
{node.holes && node.holes.length > 0 ? (
<div className="space-y-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded border px-3 py-2 ${
isEditing
? 'border-green-500 bg-green-500/10'
: 'border-border bg-muted/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-xs text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} vertices
</p>
</div>
<div className="flex items-center gap-1">
{!isEditing && (
<>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleEditHole(index)}
aria-label="Edit hole"
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleDeleteHole(index)}
aria-label="Delete hole"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No holes. Click "Add Hole" to create one.
</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -3,6 +3,7 @@
import { AnyNodeId, useScene } from '@pascal-app/core' import { AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ItemPanel } from './item-panel' import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
@@ -30,6 +31,8 @@ export function PanelManager() {
return <RoofPanel /> return <RoofPanel />
case 'slab': case 'slab':
return <SlabPanel /> return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
} }
} }
} }
+21 -19
View File
@@ -13,8 +13,8 @@ export function SlabPanel() {
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const editingHoleIndex = useEditor((s) => s.editingSlabHoleIndex) const editingHole = useEditor((s) => s.editingHole)
const setEditingHoleIndex = useEditor((s) => s.setEditingSlabHoleIndex) const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a slab // Get the first selected node if it's a slab
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
@@ -32,25 +32,25 @@ export function SlabPanel() {
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setEditingHoleIndex(null) setEditingHole(null)
}, [setSelection, setEditingHoleIndex]) }, [setSelection, setEditingHole])
// Clear hole editing state when slab is deselected // Clear hole editing state when slab is deselected
useEffect(() => { useEffect(() => {
if (!node) { if (!node) {
setEditingHoleIndex(null) setEditingHole(null)
} }
}, [node, setEditingHoleIndex]) }, [node, setEditingHole])
// Clear hole editing state on unmount // Clear hole editing state on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
setEditingHoleIndex(null) setEditingHole(null)
} }
}, [setEditingHoleIndex]) }, [setEditingHole])
const handleAddHole = useCallback(() => { const handleAddHole = useCallback(() => {
if (!node) return if (!node || !selectedId) return
// Calculate centroid of the slab polygon // Calculate centroid of the slab polygon
const polygon = node.polygon const polygon = node.polygon
@@ -74,26 +74,28 @@ export function SlabPanel() {
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] }) handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole // Enter edit mode for the new hole
setEditingHoleIndex(currentHoles.length) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, handleUpdate, setEditingHoleIndex]) }, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback( const handleEditHole = useCallback(
(index: number) => { (index: number) => {
setEditingHoleIndex(index) if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
}, },
[setEditingHoleIndex], [selectedId, setEditingHole],
) )
const handleDeleteHole = useCallback( const handleDeleteHole = useCallback(
(index: number) => { (index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index) const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles }) handleUpdate({ holes: newHoles })
if (editingHoleIndex === index) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHoleIndex(null) setEditingHole(null)
} }
}, },
[node?.holes, handleUpdate, editingHoleIndex, setEditingHoleIndex], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
// Only show if exactly one slab is selected // Only show if exactly one slab is selected
@@ -211,11 +213,11 @@ export function SlabPanel() {
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> <label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Holes Holes
</label> </label>
{editingHoleIndex !== null ? ( {editingHole?.nodeId === selectedId ? (
<button <button
type="button" type="button"
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer" className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
onClick={() => setEditingHoleIndex(null)} onClick={() => setEditingHole(null)}
> >
<span>Done Editing</span> <span>Done Editing</span>
</button> </button>
@@ -234,7 +236,7 @@ export function SlabPanel() {
<div className="space-y-2"> <div className="space-y-2">
{node.holes.map((hole, index) => { {node.holes.map((hole, index) => {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = editingHoleIndex === index const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return ( return (
<div <div
key={index} key={index}
@@ -1,6 +1,8 @@
"use client"; "use client";
import { Building2, Layers, Settings } from "lucide-react"; import { Building2, Layers, Settings } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -34,6 +36,28 @@ export function IconRail({
className, className,
)} )}
> >
{/* Pascal Logo - Link to Hub */}
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-all hover:bg-primary/90 mb-1"
>
<Image
src="/pascal-logo-shape.svg"
alt="Pascal"
width={16}
height={16}
className="h-4 w-4"
/>
</Link>
</TooltipTrigger>
<TooltipContent side="right">Back to Hub</TooltipContent>
</Tooltip>
{/* Divider */}
<div className="w-8 h-px bg-border/50 mb-1" />
{panels.map((panel) => { {panels.map((panel) => {
const Icon = panel.icon; const Icon = panel.icon;
const isActive = activePanel === panel.id; const isActive = activePanel === panel.id;
@@ -1,23 +1,31 @@
'use client' 'use client'
import { Cloud } from 'lucide-react' import { Cloud, Home } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks' import { useAuth } from '../lib/auth/hooks'
import { usePropertyStore } from '../lib/properties/store' import { usePropertyStore } from '../lib/properties/store'
import { ProfileDropdown } from './profile-dropdown' import { ProfileDropdown } from './profile-dropdown'
import { PropertyDropdown } from './property-dropdown'
import { SignInDialog } from './sign-in-dialog' import { SignInDialog } from './sign-in-dialog'
interface CloudSaveButtonProps {
propertyId?: string
}
/** /**
* CloudSaveButton - Shows authentication state and property management * CloudSaveButton - Shows authentication state and property management
* *
* Not authenticated: Shows "Save to cloud" button * Guest with local property: Shows "Save to cloud" button
* Authenticated: Shows PropertyDropdown and ProfileDropdown * Guest without property: Shows "Home" button
* Authenticated: Shows ProfileDropdown
*/ */
export function CloudSaveButton() { export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) {
const { isAuthenticated, isLoading } = useAuth() const { isAuthenticated, isLoading } = useAuth()
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
const initialize = usePropertyStore(state => state.initialize) const initialize = usePropertyStore(state => state.initialize)
const router = useRouter()
const isLocalProperty = propertyId?.startsWith('local_')
// Initialize property store when authenticated // Initialize property store when authenticated
useEffect(() => { useEffect(() => {
@@ -36,7 +44,8 @@ export function CloudSaveButton() {
) )
} }
if (!isAuthenticated) { // Guest user with local property
if (!isAuthenticated && isLocalProperty) {
return ( return (
<> <>
<div className="pointer-events-auto"> <div className="pointer-events-auto">
@@ -53,12 +62,25 @@ export function CloudSaveButton() {
) )
} }
// Guest user (no property context or browsing)
if (!isAuthenticated) {
return ( return (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<div className="flex items-center gap-2"> <button
<PropertyDropdown /> 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">
<ProfileDropdown /> <ProfileDropdown />
</div>
</div> </div>
) )
} }
@@ -0,0 +1,224 @@
'use client'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuth } from '../lib/auth/hooks'
import type { LocalProperty } from '../lib/local-storage/property-store'
import { createLocalProperty, getLocalProperties } from '../lib/local-storage/property-store'
import { getPublicProperties, getUserProperties } from '../lib/properties/actions'
import type { Property } from '../lib/properties/types'
import { CreatePropertyButton } from './create-property-button'
import { NewPropertyDialog } from './new-property-dialog'
import { ProfileDropdown } from './profile-dropdown'
import { PropertyGrid } from './property-grid'
import { SignInDialog } from './sign-in-dialog'
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">
<div className="flex items-center gap-2">
<Image
src="/pascal-logo-shape.svg"
alt="Pascal"
width={64}
height={64}
className="h-5 w-5"
/>
<h1 className="text-2xl font-bold">Hub</h1>
</div>
{!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 { useState } from 'react'
import { createProperty } from '../lib/properties/actions' import { createProperty } from '../lib/properties/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch'
import { GoogleAddressSearch } from './google-address-search' import { GoogleAddressSearch } from './google-address-search'
interface NewPropertyDialogProps { interface NewPropertyDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSuccess?: (propertyId: string) => void onSuccess?: (propertyId: string) => void
localPropertyData?: {
id: string
name: string
sceneGraph: any
}
} }
interface AddressData { interface AddressData {
@@ -26,8 +32,9 @@ interface AddressData {
/** /**
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search * 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 [address, setAddress] = useState<AddressData | null>(null)
const [isPrivate, setIsPrivate] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -58,11 +65,14 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
state: address.state, state: address.state,
postalCode: address.postalCode, postalCode: address.postalCode,
country: address.country || 'US', country: address.country || 'US',
isPrivate,
sceneGraph: localPropertyData?.sceneGraph,
}) })
if (result.success && result.data) { if (result.success && result.data) {
onOpenChange(false) onOpenChange(false)
setAddress(null) setAddress(null)
setIsPrivate(false)
onSuccess?.(result.data.id) onSuccess?.(result.data.id)
} else { } else {
setError(result.error || 'Failed to create property') setError(result.error || 'Failed to create property')
@@ -78,6 +88,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
if (!isCreating) { if (!isCreating) {
onOpenChange(false) onOpenChange(false)
setAddress(null) setAddress(null)
setIsPrivate(false)
setError(null) setError(null)
} }
} }
@@ -112,6 +123,31 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</div> </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 && ( {error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error} {error}
@@ -0,0 +1,235 @@
'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
const data = result.data
setUserLikes((prev) => ({ ...prev, [propertyId]: data.liked }))
setLikeCounts((prev) => ({ ...prev, [propertyId]: 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 { type AnyNodeId, 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 as AnyNodeId[])
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)
}
@@ -8,7 +8,7 @@
import { createServerSupabaseClient } from '../database/server' import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server' import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator' import { createId } from '../utils/id-generator'
import type { CreatePropertyParams, Property } from './types' import type { CreatePropertyParams, Property, Database } from './types'
export type ActionResult<T = unknown> = { export type ActionResult<T = unknown> = {
success: boolean success: boolean
@@ -42,6 +42,7 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
address:properties_addresses(*) address:properties_addresses(*)
`) `)
.eq('owner_id', session.user.id) .eq('owner_id', session.user.id)
.order('created_at', { ascending: false })
if (error) { if (error) {
return { return {
@@ -218,6 +219,7 @@ export async function createProperty(params: CreatePropertyParams): Promise<Acti
name: params.name, name: params.name,
address_id: address.id, address_id: address.id,
owner_id: session.user.id, owner_id: session.user.id,
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
details_json: { details_json: {
coordinates: params.center, coordinates: params.center,
createdFrom: 'editor-app', 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,
} as any)
if (modelError) {
console.error('Failed to create model:', modelError)
// Don't fail the property creation if model creation fails
}
}
return { return {
success: true, success: true,
data: data as Property, data: data as Property,
@@ -326,3 +344,550 @@ 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: undefined,
}
}
// Check if user can view this property
// Allow if: property is public OR user owns it
const propertyData = property as any
const isOwner = session?.user && propertyData.owner_id === session.user.id
const isPublic = propertyData.is_private === false
if (!isPublic && !isOwner) {
return {
success: false,
error: 'Property is private',
data: undefined,
}
}
// 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: propertyData as Property,
model: model || null,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch property',
data: undefined,
}
}
}
/**
* 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,
} as any)
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 as any)?.owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// Update privacy
const { error } = await (supabase
.from('properties') as any)
.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 as any).owner_id !== session.user.id) {
return {
success: false,
error: 'Unauthorized',
}
}
// Update address
const { error } = await (supabase
.from('properties_addresses') as any)
.update(addressData)
.eq('id', (property as any).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') as any).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') as any).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') as any).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 as any).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 as any).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') as any)
.delete()
.eq('id', (existingLike as any).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') as any).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,
} as any)
// Update the property's like count cache
await (supabase
.from('properties') as any)
.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, isLoading: false,
error: null error: null
}) })
// Note: Auto-select logic removed - now using URL-based routing
// If no active property, auto-select the first one // The URL parameter determines which property to load
if (!result.data) {
const propertiesResult = await getUserProperties()
if (propertiesResult.success && propertiesResult.data && propertiesResult.data.length > 0) {
const firstProperty = propertiesResult.data[0]
if (firstProperty) {
await get().setActiveProperty(firstProperty.id)
}
}
}
} else { } else {
set({ set({
error: result.error || 'Failed to fetch active property', error: result.error || 'Failed to fetch active property',
@@ -3,6 +3,90 @@
* Isolated from monorepo database schema * Isolated from monorepo database schema
*/ */
// Database table row types
export type DbProperty = {
id: string
name: string
owner_id: string
organization_id: string | null
address_id: string
created_at: string
updated_at: string
is_private: boolean
views: number
likes: number
thumbnail_url: string | null
}
export type DbPropertyAddress = {
id: string
street_number?: string
route?: string
city?: string
state?: string
postal_code?: string
country?: string
latitude?: string
longitude?: string
created_at: string
updated_at: string
}
export type DbPropertyModel = {
id: string
property_id: string
version: number
scene_graph: any
created_at: string
updated_at: string
deleted_at: string | null
}
export type DbPropertyLike = {
id: string
property_id: string
user_id: string
created_at: string
}
// Database schema type for Supabase
export type Database = {
public: {
Tables: {
properties: {
Row: DbProperty
Insert: Omit<DbProperty, 'created_at' | 'updated_at' | 'views' | 'likes'>
Update: Partial<Omit<DbProperty, 'id' | 'created_at' | 'updated_at'>>
}
properties_addresses: {
Row: DbPropertyAddress
Insert: Omit<DbPropertyAddress, 'created_at' | 'updated_at'>
Update: Partial<Omit<DbPropertyAddress, 'id' | 'created_at' | 'updated_at'>>
}
properties_models: {
Row: DbPropertyModel
Insert: Omit<DbPropertyModel, 'created_at' | 'updated_at' | 'deleted_at'>
Update: Partial<Omit<DbPropertyModel, 'id' | 'created_at' | 'updated_at'>>
}
property_likes: {
Row: DbPropertyLike
Insert: Omit<DbPropertyLike, 'created_at'>
Update: Partial<Omit<DbPropertyLike, 'id' | 'created_at'>>
}
}
Functions: {
increment_property_views: {
Args: { property_id: string }
Returns: undefined
}
get_property_like_count: {
Args: { property_id: string }
Returns: number
}
}
}
}
export type Property = { export type Property = {
id: string id: string
name: string name: string
@@ -11,6 +95,11 @@ export type Property = {
address_id: string address_id: string
created_at: string created_at: string
updated_at: string updated_at: string
// Community features
is_private: boolean
views: number
likes: number
thumbnail_url: string | null
address: { address: {
id: string id: string
street_number?: string street_number?: string
@@ -40,4 +129,6 @@ export type CreatePropertyParams = {
country?: string country?: string
countryLong?: string countryLong?: string
rawJson?: Record<string, unknown> 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 '/'
}
+11
View File
@@ -0,0 +1,11 @@
<svg width="498" height="100" viewBox="0 0 498 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M498 8V99.648H486.084V8H498Z" fill="black"/>
<path d="M435.895 99.56C429.074 99.56 423.591 97.768 419.446 94.184C415.302 90.6 413.229 86.0347 413.229 80.488C413.229 68.5413 421.691 61.928 438.615 60.648L457.525 59.24V57.192C457.525 52.84 456.1 49.4267 453.251 46.952C450.402 44.392 446.689 43.112 442.112 43.112H441.594C437.795 43.112 434.514 44.008 431.751 45.8C429.074 47.5067 427.39 49.8533 426.699 52.84H414.654C415.518 47.0373 418.41 42.3013 423.332 38.632C428.34 34.8773 434.6 33 442.112 33C451.006 33 457.827 35.304 462.576 39.912C467.325 44.52 469.7 50.4507 469.7 57.704V84.2C469.7 90.6 469.916 95.2933 470.348 98.28H458.95C458.691 97 458.475 95.4213 458.302 93.544C458.216 91.5813 458.173 89.96 458.173 88.68C456.273 91.6667 453.337 94.2267 449.365 96.36C445.393 98.4933 440.903 99.56 435.895 99.56ZM438.356 89.448C441.465 89.448 444.487 88.7653 447.423 87.4C450.358 86.0347 452.776 83.9867 454.676 81.256C456.575 78.5253 457.525 75.24 457.525 71.4V68.584L440.169 69.736C435.248 70.0773 431.535 71.144 429.031 72.936C426.527 74.6427 425.275 76.9893 425.275 79.976C425.275 82.7067 426.397 84.968 428.642 86.76C430.974 88.552 434.039 89.448 437.838 89.448H438.356Z" fill="black"/>
<path d="M376.049 99.816C366.637 99.816 358.823 96.6587 352.606 90.344C346.475 83.8587 343.41 75.88 343.41 66.408C343.41 56.7653 346.475 48.7867 352.606 42.472C358.823 36.1573 366.637 33 376.049 33C382.525 33 388.181 34.6213 393.016 37.864C397.852 41.0213 401.478 45.416 403.896 51.048L393.016 55.528C389.649 47.6773 383.734 43.752 375.272 43.752C369.832 43.752 365.126 45.928 361.154 50.28C357.269 54.632 355.326 60.008 355.326 66.408C355.326 72.808 357.269 78.184 361.154 82.536C365.126 86.888 369.832 89.064 375.272 89.064C383.993 89.064 390.124 85.1387 393.664 77.288L404.284 81.768C401.953 87.4 398.283 91.8373 393.275 95.08C388.353 98.2373 382.611 99.816 376.049 99.816Z" fill="black"/>
<path d="M334.4 80.36C334.4 85.8213 331.983 90.4293 327.147 94.184C322.312 97.9387 316.224 99.816 308.885 99.816C302.495 99.816 296.883 98.1947 292.047 94.952C287.212 91.624 283.758 87.272 281.686 81.896L292.307 77.416C293.861 81.1707 296.106 84.1147 299.042 86.248C302.064 88.296 305.345 89.32 308.885 89.32C312.684 89.32 315.836 88.5093 318.34 86.888C320.93 85.2667 322.226 83.3467 322.226 81.128C322.226 77.1173 319.117 74.1733 312.9 72.296L302.021 69.608C289.673 66.536 283.499 60.648 283.499 51.944C283.499 46.2267 285.831 41.6613 290.493 38.248C295.242 34.7493 301.287 33 308.626 33C314.239 33 319.29 34.3227 323.78 36.968C328.356 39.6133 331.551 43.1547 333.364 47.592L322.744 51.944C321.535 49.2987 319.549 47.2507 316.786 45.8C314.109 44.264 311.087 43.496 307.719 43.496C304.611 43.496 301.805 44.264 299.301 45.8C296.883 47.336 295.674 49.2133 295.674 51.432C295.674 55.016 299.085 57.576 305.906 59.112L315.491 61.544C328.097 64.616 334.4 70.888 334.4 80.36Z" fill="black"/>
<path d="M237.568 99.56C230.747 99.56 225.264 97.768 221.119 94.184C216.975 90.6 214.902 86.0347 214.902 80.488C214.902 68.5413 223.364 61.928 240.288 60.648L259.198 59.24V57.192C259.198 52.84 257.773 49.4267 254.924 46.952C252.075 44.392 248.362 43.112 243.785 43.112H243.267C239.468 43.112 236.187 44.008 233.424 45.8C230.747 47.5067 229.063 49.8533 228.372 52.84H216.327C217.191 47.0373 220.083 42.3013 225.005 38.632C230.013 34.8773 236.273 33 243.785 33C252.679 33 259.5 35.304 264.249 39.912C268.998 44.52 271.373 50.4507 271.373 57.704V84.2C271.373 90.6 271.589 95.2933 272.021 98.28H260.623C260.364 97 260.148 95.4213 259.975 93.544C259.889 91.5813 259.846 89.96 259.846 88.68C257.946 91.6667 255.01 94.2267 251.038 96.36C247.066 98.4933 242.576 99.56 237.568 99.56ZM240.029 89.448C243.138 89.448 246.16 88.7653 249.096 87.4C252.031 86.0347 254.449 83.9867 256.349 81.256C258.248 78.5253 259.198 75.24 259.198 71.4V68.584L241.842 69.736C236.921 70.0773 233.208 71.144 230.704 72.936C228.2 74.6427 226.948 76.9893 226.948 79.976C226.948 82.7067 228.07 84.968 230.315 86.76C232.647 88.552 235.712 89.448 239.511 89.448H240.029Z" fill="black"/>
<path d="M138 0H181.473C187.869 0 193.566 1.19048 198.562 3.57143C203.659 5.85715 207.657 9.28572 210.555 13.8571C213.453 18.3333 214.902 23.7143 214.902 30C214.902 36.2857 213.453 41.7143 210.555 46.2857C207.657 50.7619 203.659 54.1905 198.562 56.5714C193.566 58.8571 187.869 60 181.473 60H151.492V100H138V0ZM179.224 48.2857C186.22 48.2857 191.667 46.7619 195.564 43.7143C199.462 40.5714 201.411 36 201.411 30C201.411 24 199.462 19.4762 195.564 16.4286C191.667 13.2857 186.22 11.7143 179.224 11.7143H151.492V48.2857H179.224Z" fill="black"/>
<rect y="60" width="20" height="40" fill="black"/>
<rect x="40" y="30" width="20" height="40" fill="black"/>
<rect x="80" width="20" height="40" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="60" width="20" height="40" fill="black"/>
<rect x="40" y="30" width="20" height="40" fill="black"/>
<rect x="80" width="20" height="40" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="360" height="100" viewBox="0 0 360 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M360 8V99.648H348.084V8H360Z" fill="black"/>
<path d="M297.895 99.56C291.074 99.56 285.591 97.768 281.446 94.184C277.302 90.6 275.229 86.0347 275.229 80.488C275.229 68.5413 283.691 61.928 300.615 60.648L319.525 59.24V57.192C319.525 52.84 318.1 49.4267 315.251 46.952C312.402 44.392 308.689 43.112 304.112 43.112H303.594C299.795 43.112 296.514 44.008 293.751 45.8C291.074 47.5067 289.39 49.8533 288.699 52.84H276.654C277.518 47.0373 280.41 42.3013 285.332 38.632C290.34 34.8773 296.6 33 304.112 33C313.006 33 319.827 35.304 324.576 39.912C329.325 44.52 331.7 50.4507 331.7 57.704V84.2C331.7 90.6 331.916 95.2933 332.348 98.28H320.95C320.691 97 320.475 95.4213 320.302 93.544C320.216 91.5813 320.173 89.96 320.173 88.68C318.273 91.6667 315.337 94.2267 311.365 96.36C307.393 98.4933 302.903 99.56 297.895 99.56ZM300.356 89.448C303.465 89.448 306.487 88.7653 309.423 87.4C312.358 86.0347 314.776 83.9867 316.676 81.256C318.575 78.5253 319.525 75.24 319.525 71.4V68.584L302.169 69.736C297.248 70.0773 293.535 71.144 291.031 72.936C288.527 74.6427 287.275 76.9893 287.275 79.976C287.275 82.7067 288.397 84.968 290.642 86.76C292.974 88.552 296.039 89.448 299.838 89.448H300.356Z" fill="black"/>
<path d="M238.049 99.816C228.637 99.816 220.823 96.6587 214.606 90.344C208.475 83.8587 205.41 75.88 205.41 66.408C205.41 56.7653 208.475 48.7867 214.606 42.472C220.823 36.1573 228.637 33 238.049 33C244.525 33 250.181 34.6213 255.016 37.864C259.852 41.0213 263.478 45.416 265.896 51.048L255.016 55.528C251.649 47.6773 245.734 43.752 237.272 43.752C231.832 43.752 227.126 45.928 223.154 50.28C219.269 54.632 217.326 60.008 217.326 66.408C217.326 72.808 219.269 78.184 223.154 82.536C227.126 86.888 231.832 89.064 237.272 89.064C245.993 89.064 252.124 85.1387 255.664 77.288L266.284 81.768C263.953 87.4 260.283 91.8373 255.275 95.08C250.353 98.2373 244.611 99.816 238.049 99.816Z" fill="black"/>
<path d="M196.4 80.36C196.4 85.8213 193.983 90.4293 189.147 94.184C184.312 97.9387 178.224 99.816 170.885 99.816C164.495 99.816 158.883 98.1947 154.047 94.952C149.212 91.624 145.758 87.272 143.686 81.896L154.307 77.416C155.861 81.1707 158.106 84.1147 161.042 86.248C164.064 88.296 167.345 89.32 170.885 89.32C174.684 89.32 177.836 88.5093 180.34 86.888C182.93 85.2667 184.226 83.3467 184.226 81.128C184.226 77.1173 181.117 74.1733 174.9 72.296L164.021 69.608C151.673 66.536 145.499 60.648 145.499 51.944C145.499 46.2267 147.831 41.6613 152.493 38.248C157.242 34.7493 163.287 33 170.626 33C176.239 33 181.29 34.3227 185.78 36.968C190.356 39.6133 193.551 43.1547 195.364 47.592L184.744 51.944C183.535 49.2987 181.549 47.2507 178.786 45.8C176.109 44.264 173.087 43.496 169.719 43.496C166.611 43.496 163.805 44.264 161.301 45.8C158.883 47.336 157.674 49.2133 157.674 51.432C157.674 55.016 161.085 57.576 167.906 59.112L177.491 61.544C190.097 64.616 196.4 70.888 196.4 80.36Z" fill="black"/>
<path d="M99.5683 99.56C92.7469 99.56 87.2639 97.768 83.1193 94.184C78.9747 90.6 76.9023 86.0347 76.9023 80.488C76.9023 68.5413 85.3643 61.928 102.288 60.648L121.198 59.24V57.192C121.198 52.84 119.773 49.4267 116.924 46.952C114.075 44.392 110.362 43.112 105.785 43.112H105.267C101.468 43.112 98.1867 44.008 95.4237 45.8C92.7469 47.5067 91.0632 49.8533 90.3724 52.84H78.3271C79.1905 47.0373 82.0831 42.3013 87.0049 38.632C92.013 34.8773 98.2731 33 105.785 33C114.679 33 121.5 35.304 126.249 39.912C130.998 44.52 133.373 50.4507 133.373 57.704V84.2C133.373 90.6 133.589 95.2933 134.021 98.28H122.623C122.364 97 122.148 95.4213 121.975 93.544C121.889 91.5813 121.846 89.96 121.846 88.68C119.946 91.6667 117.01 94.2267 113.038 96.36C109.066 98.4933 104.576 99.56 99.5683 99.56ZM102.029 89.448C105.138 89.448 108.16 88.7653 111.096 87.4C114.031 86.0347 116.449 83.9867 118.349 81.256C120.248 78.5253 121.198 75.24 121.198 71.4V68.584L103.842 69.736C98.9207 70.0773 95.2078 71.144 92.7037 72.936C90.1997 74.6427 88.9477 76.9893 88.9477 79.976C88.9477 82.7067 90.0702 84.968 92.3152 86.76C94.6465 88.552 97.7118 89.448 101.511 89.448H102.029Z" fill="black"/>
<path d="M0 0H43.4731C49.8691 0 55.5655 1.19048 60.5625 3.57143C65.6593 5.85715 69.6568 9.28572 72.555 13.8571C75.4532 18.3333 76.9023 23.7143 76.9023 30C76.9023 36.2857 75.4532 41.7143 72.555 46.2857C69.6568 50.7619 65.6593 54.1905 60.5625 56.5714C55.5655 58.8571 49.8691 60 43.4731 60H13.4916V100H0V0ZM41.2244 48.2857C48.2201 48.2857 53.6667 46.7619 57.5643 43.7143C61.4619 40.5714 63.4107 36 63.4107 30C63.4107 24 61.4619 19.4762 57.5643 16.4286C53.6667 13.2857 48.2201 11.7143 41.2244 11.7143H13.4916V48.2857H41.2244Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+5 -5
View File
@@ -69,9 +69,9 @@ type EditorState = {
// Space detection for cutaway mode // Space detection for cutaway mode
spaces: Record<string, Space> spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void setSpaces: (spaces: Record<string, Space>) => void
// Slab hole editing // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingSlabHoleIndex: number | null editingHole: { nodeId: string; holeIndex: number } | null
setEditingSlabHoleIndex: (index: number | null) => void setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
} }
const useEditor = create<EditorState>()((set, get) => ({ const useEditor = create<EditorState>()((set, get) => ({
@@ -198,8 +198,8 @@ const useEditor = create<EditorState>()((set, get) => ({
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingSlabHoleIndex: null, editingHole: null,
setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }), setEditingHole: (hole) => set({ editingHole: hole }),
})) }))
export default useEditor export default useEditor
@@ -468,7 +468,8 @@ export class SpatialGridManager {
if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {
// Check if point is in any hole // Check if point is in any hole
let inHole = false let inHole = false
for (const hole of slab.holes) { const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(x, z, hole)) { if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
inHole = true inHole = true
break break
@@ -507,7 +508,8 @@ export class SpatialGridManager {
// We consider it entirely in a hole if the item center is in the hole // We consider it entirely in a hole if the item center is in the hole
let inHole = false let inHole = false
const [cx, , cz] = position const [cx, , cz] = position
for (const hole of slab.holes) { const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) { if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
inHole = true inHole = true
break break
@@ -546,7 +548,8 @@ export class SpatialGridManager {
let inHole = false let inHole = false
const midX = (start[0] + end[0]) / 2 const midX = (start[0] + end[0]) / 2
const midZ = (start[1] + end[1]) / 2 const midZ = (start[1] + end[1]) / 2
for (const hole of slab.holes) { const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) { if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) {
inHole = true inHole = true
break break
@@ -566,7 +569,7 @@ export class SpatialGridManager {
/** /**
* Check if an item can be placed on a ceiling. * Check if an item can be placed on a ceiling.
* Validates that the footprint is within the ceiling polygon and doesn't overlap other ceiling items. * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items.
*/ */
canPlaceOnCeiling( canPlaceOnCeiling(
ceilingId: string, ceilingId: string,
@@ -588,6 +591,15 @@ export class SpatialGridManager {
} }
} }
// Check if item center is in any hole (if so, it cannot be placed)
const [centerX, , centerZ] = position
const holes = ceiling.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) {
return { valid: false, conflictIds: [] }
}
}
// Check for overlaps with other ceiling items // Check for overlaps with other ceiling items
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds) return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
} }
@@ -4,6 +4,9 @@ type AttachType = 'wall' | 'wall-side'
// Small tolerance for floating point comparison to allow adjacent items // Small tolerance for floating point comparison to allow adjacent items
const EPSILON = 0.001 const EPSILON = 0.001
// Margin from ceiling/floor when auto-snapping items
const AUTO_SNAP_MARGIN = 0.05
interface WallItemPlacement { interface WallItemPlacement {
itemId: string itemId: string
wallId: string wallId: string
@@ -15,12 +18,42 @@ interface WallItemPlacement {
side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall') side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall')
} }
/**
* Auto-adjust Y position to fit item within wall bounds
* Returns the adjusted Y position (bottom of item)
*/
function autoAdjustYPosition(
yBottom: number,
itemHeight: number,
wallHeight: number,
): { adjustedY: number; wasAdjusted: boolean } {
const yTop = yBottom + itemHeight
// If fits perfectly, no adjustment needed
if (yBottom >= 0 && yTop <= wallHeight) {
return { adjustedY: yBottom, wasAdjusted: false }
}
// If too high (top exceeds wall height), snap down from ceiling
if (yTop > wallHeight) {
const adjustedY = wallHeight - itemHeight - AUTO_SNAP_MARGIN
return { adjustedY: Math.max(0, adjustedY), wasAdjusted: true }
}
// If too low (bottom below floor), snap up from floor
if (yBottom < 0) {
return { adjustedY: AUTO_SNAP_MARGIN, wasAdjusted: true }
}
return { adjustedY: yBottom, wasAdjusted: false }
}
export class WallSpatialGrid { export class WallSpatialGrid {
private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup) private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
/** /**
* Check if an item can be placed on a wall * Check if an item can be placed on a wall with auto-adjustment for vertical position
* @param wallId - The wall to place on * @param wallId - The wall to place on
* @param wallLength - Length of the wall * @param wallLength - Length of the wall
* @param wallHeight - Height of the wall * @param wallHeight - Height of the wall
@@ -31,6 +64,7 @@ export class WallSpatialGrid {
* @param attachType - 'wall' (blocks both sides) or 'wall-side' (blocks one side) * @param attachType - 'wall' (blocks both sides) or 'wall-side' (blocks one side)
* @param side - Which side for 'wall-side' items * @param side - Which side for 'wall-side' items
* @param ignoreIds - Item IDs to ignore in conflict check * @param ignoreIds - Item IDs to ignore in conflict check
* @returns Validation result with auto-adjusted Y position if needed
*/ */
canPlaceOnWall( canPlaceOnWall(
wallId: string, wallId: string,
@@ -43,19 +77,21 @@ export class WallSpatialGrid {
attachType: AttachType = 'wall', attachType: AttachType = 'wall',
side?: WallSide, side?: WallSide,
ignoreIds: string[] = [], ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } { ): { valid: boolean; conflictIds: string[]; adjustedY: number; wasAdjusted: boolean } {
const halfW = itemWidth / wallLength / 2 const halfW = itemWidth / wallLength / 2
const tStart = tCenter - halfW const tStart = tCenter - halfW
const tEnd = tCenter + halfW const tEnd = tCenter + halfW
// yBottom is the bottom of the item, so yEnd = yBottom + itemHeight
const yStart = yBottom
const yEnd = yBottom + itemHeight
// Check wall boundaries // Check horizontal boundaries (still reject if item exceeds wall width)
if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) { if (tStart < 0 || tEnd > 1) {
return { valid: false, conflictIds: [] } return { valid: false, conflictIds: [], adjustedY: yBottom, wasAdjusted: false }
} }
// Auto-adjust vertical position to fit within wall bounds
const { adjustedY, wasAdjusted } = autoAdjustYPosition(yBottom, itemHeight, wallHeight)
const yStart = adjustedY
const yEnd = adjustedY + itemHeight
const existing = this.wallItems.get(wallId) ?? [] const existing = this.wallItems.get(wallId) ?? []
const ignoreSet = new Set(ignoreIds) const ignoreSet = new Set(ignoreIds)
const conflicts: string[] = [] const conflicts: string[] = []
@@ -76,7 +112,7 @@ export class WallSpatialGrid {
} }
} }
return { valid: conflicts.length === 0, conflictIds: conflicts } return { valid: conflicts.length === 0, conflictIds: conflicts, adjustedY, wasAdjusted }
} }
/** /**
@@ -10,11 +10,13 @@ export const CeilingNode = BaseNode.extend({
// Specific props // Specific props
// Polygon boundary - array of [x, z] coordinates defining the ceiling // Polygon boundary - array of [x, z] coordinates defining the ceiling
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
height: z.number().default(2.5), // Height in meters height: z.number().default(2.5), // Height in meters
}).describe( }).describe(
dedent` dedent`
Ceiling node - used to represent a ceiling in the building Ceiling node - used to represent a ceiling in the building
- polygon: array of [x, z] points defining the ceiling boundary - polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling
`, `,
) )
@@ -70,6 +70,24 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
} }
shape.closePath() shape.closePath()
// Add holes to the shape
const holes = ceilingNode.holes || []
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
const holeFirstPt = holePolygon[0]!
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
holePath.lineTo(pt[0], -pt[1])
}
holePath.closePath()
shape.holes.push(holePath)
}
// Create flat shape geometry (no extrusion) // Create flat shape geometry (no extrusion)
const geometry = new THREE.ShapeGeometry(shape) const geometry = new THREE.ShapeGeometry(shape)
@@ -124,7 +124,8 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
shape.closePath() shape.closePath()
// Add holes to the shape // Add holes to the shape
for (const holePolygon of slabNode.holes) { const holes = slabNode.holes || []
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue if (holePolygon.length < 3) continue
const holePath = new THREE.Path() const holePath = new THREE.Path()
@@ -19,11 +19,19 @@ export const properties = pgTable(
.references(() => users.id, { onDelete: 'set null' }), .references(() => users.id, { onDelete: 'set null' }),
detailsJson: t.jsonb('details_json'), detailsJson: t.jsonb('details_json'),
metadata: t.jsonb('metadata'), metadata: t.jsonb('metadata'),
// Community features
isPrivate: t.boolean('is_private').notNull().default(true),
views: t.integer('views').notNull().default(0),
likes: t.integer('likes').notNull().default(0),
thumbnailUrl: t.text('thumbnail_url'),
...timestampsColumns, ...timestampsColumns,
}), }),
(t) => [ (t) => [
index('property_address_idx').on(t.addressId), index('property_address_idx').on(t.addressId),
index('property_owner_idx').on(t.ownerId), index('property_owner_idx').on(t.ownerId),
index('property_is_private_idx').on(t.isPrivate),
index('property_views_idx').on(t.views),
index('property_likes_idx').on(t.likes),
], ],
).enableRLS() ).enableRLS()
@@ -0,0 +1,13 @@
-- Add community features to properties table
ALTER TABLE properties ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE properties ADD COLUMN IF NOT EXISTS views INTEGER NOT NULL DEFAULT 0;
ALTER TABLE properties ADD COLUMN IF NOT EXISTS likes INTEGER NOT NULL DEFAULT 0;
ALTER TABLE properties ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
-- Create indexes for community queries
CREATE INDEX IF NOT EXISTS idx_properties_is_private ON properties(is_private) WHERE is_private = false;
CREATE INDEX IF NOT EXISTS idx_properties_views ON properties(views DESC);
CREATE INDEX IF NOT EXISTS idx_properties_likes ON properties(likes DESC);
-- Set existing properties to private (user opt-in to share)
UPDATE properties SET is_private = true WHERE is_private IS NULL;
@@ -0,0 +1,78 @@
-- Drop existing RLS policies for properties
DROP POLICY IF EXISTS "Users can view their own properties" ON properties;
DROP POLICY IF EXISTS "Users can insert their own properties" ON properties;
DROP POLICY IF EXISTS "Users can update their own properties" ON properties;
DROP POLICY IF EXISTS "Users can delete their own properties" ON properties;
-- New RLS policy: Users can view their own properties OR public properties
CREATE POLICY "Users can view own or public properties"
ON properties FOR SELECT
USING (
owner_id = current_setting('app.user_id', true)::TEXT
OR is_private = false
OR owner_id IS NULL
);
-- Keep other policies the same (insert/update/delete still require ownership)
CREATE POLICY "Users can insert their own properties"
ON properties FOR INSERT
WITH CHECK (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
CREATE POLICY "Users can update their own properties"
ON properties FOR UPDATE
USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
CREATE POLICY "Users can delete their own properties"
ON properties FOR DELETE
USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL);
-- Drop existing RLS policies for models
DROP POLICY IF EXISTS "Users can view models of their own properties" ON properties_models;
DROP POLICY IF EXISTS "Users can insert models for their own properties" ON properties_models;
DROP POLICY IF EXISTS "Users can update models of their own properties" ON properties_models;
DROP POLICY IF EXISTS "Users can delete models of their own properties" ON properties_models;
-- Update models policy: Users can view models of their own properties OR public properties
CREATE POLICY "Users can view models of own or public properties"
ON properties_models FOR SELECT
USING (
EXISTS (
SELECT 1 FROM properties
WHERE properties.id = properties_models.property_id
AND (
properties.owner_id = current_setting('app.user_id', true)::TEXT
OR properties.is_private = false
)
)
);
-- Keep other model policies the same
CREATE POLICY "Users can insert models for their own properties"
ON properties_models FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM properties
WHERE properties.id = properties_models.property_id
AND properties.owner_id = current_setting('app.user_id', true)::TEXT
)
);
CREATE POLICY "Users can update models of their own properties"
ON properties_models FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM properties
WHERE properties.id = properties_models.property_id
AND properties.owner_id = current_setting('app.user_id', true)::TEXT
)
);
CREATE POLICY "Users can delete models of their own properties"
ON properties_models FOR DELETE
USING (
EXISTS (
SELECT 1 FROM properties
WHERE properties.id = properties_models.property_id
AND properties.owner_id = current_setting('app.user_id', true)::TEXT
)
);
@@ -0,0 +1,9 @@
-- Function to atomically increment view count
CREATE OR REPLACE FUNCTION increment_property_views(property_id TEXT)
RETURNS void AS $$
BEGIN
UPDATE properties
SET views = views + 1
WHERE id = property_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -0,0 +1,41 @@
-- Create property_likes table to track user likes
CREATE TABLE IF NOT EXISTS property_likes (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure a user can only like a property once
UNIQUE(property_id, user_id)
);
-- Enable RLS
ALTER TABLE property_likes ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users can view all likes (to see like counts)
CREATE POLICY "Anyone can view likes"
ON property_likes FOR SELECT
USING (true);
-- Users can insert their own likes
CREATE POLICY "Users can create their own likes"
ON property_likes FOR INSERT
WITH CHECK (user_id = current_setting('app.user_id', true)::TEXT);
-- Users can delete their own likes
CREATE POLICY "Users can delete their own likes"
ON property_likes FOR DELETE
USING (user_id = current_setting('app.user_id', true)::TEXT);
-- Create index for efficient querying
CREATE INDEX IF NOT EXISTS idx_property_likes_property_id ON property_likes(property_id);
CREATE INDEX IF NOT EXISTS idx_property_likes_user_id ON property_likes(user_id);
-- Function to get like count for a property
CREATE OR REPLACE FUNCTION get_property_like_count(property_id TEXT)
RETURNS INTEGER AS $$
BEGIN
RETURN (SELECT COUNT(*)::INTEGER FROM property_likes WHERE property_likes.property_id = $1);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
@@ -104,9 +104,6 @@ const PostProcessingPasses = () => {
scenePassColor.a, scenePassColor.a,
) )
// TRAA (Temporal Reprojection Anti-Aliasing)
const traaPass = traa(compositePass, scenePassDepth, scenePassVelocity, camera)
function generateSelectedOutlinePass() { function generateSelectedOutlinePass() {
const edgeStrength = uniform(3) const edgeStrength = uniform(3)
const edgeGlow = uniform(0) const edgeGlow = uniform(0)
@@ -160,10 +157,13 @@ const PostProcessingPasses = () => {
const selectedOutlinePass = generateSelectedOutlinePass() const selectedOutlinePass = generateSelectedOutlinePass()
const hoverOutlinePass = generateHoverOutlinePass() const hoverOutlinePass = generateHoverOutlinePass()
// Combine SSGI output with outlines // Combine composite with outlines BEFORE applying TRAA
const finalOutput = SSGI_PARAMS.enabled const compositeWithOutlines = SSGI_PARAMS.enabled
? selectedOutlinePass.add(hoverOutlinePass).add(traaPass) ? vec4(add(compositePass.rgb, selectedOutlinePass.add(hoverOutlinePass)), compositePass.a)
: selectedOutlinePass.add(hoverOutlinePass).add(scenePassColor) : vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a)
// TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything
const finalOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera)
postProcessing.outputNode = finalOutput postProcessing.outputNode = finalOutput
postProcessingRef.current = postProcessing postProcessingRef.current = postProcessing