Merge pull request #96 from pascalorg/feat/fixes-placements-and-tools
Feat/fixes placements and tools
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '/'
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user