@pascal-app/editor 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -4,28 +4,60 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  getClampedWallCurveOffset,
7
+ getEffectiveWallSurfaceMaterial,
7
8
  getMaxWallCurveOffset,
8
9
  getWallCurveLength,
10
+ getWallSurfaceMaterialSignature,
11
+ type MaterialSchema,
9
12
  normalizeWallCurveOffset,
10
13
  useScene,
11
14
  type WallNode,
15
+ type WallSurfaceSide,
12
16
  } from '@pascal-app/core'
13
17
  import { useViewer } from '@pascal-app/viewer'
14
18
  import { Move, Spline } from 'lucide-react'
15
- import { useCallback } from 'react'
19
+ import { useCallback, useMemo } from 'react'
16
20
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
21
  import useEditor from '../../../store/use-editor'
18
22
  import { ActionButton, ActionGroup } from '../controls/action-button'
23
+ import { MaterialPicker } from '../controls/material-picker'
19
24
  import { PanelSection } from '../controls/panel-section'
20
25
  import { SliderControl } from '../controls/slider-control'
21
26
  import { PanelWrapper } from './panel-wrapper'
22
27
 
28
+ function buildWallSurfaceMaterialPatch(
29
+ node: WallNode,
30
+ targetSide: WallSurfaceSide | null,
31
+ material: MaterialSchema | undefined,
32
+ materialPreset: string | undefined,
33
+ ): Partial<WallNode> {
34
+ const nextSurfaceMaterial = { material, materialPreset }
35
+ const nextInterior =
36
+ targetSide === null || targetSide === 'interior'
37
+ ? nextSurfaceMaterial
38
+ : getEffectiveWallSurfaceMaterial(node, 'interior')
39
+ const nextExterior =
40
+ targetSide === null || targetSide === 'exterior'
41
+ ? nextSurfaceMaterial
42
+ : getEffectiveWallSurfaceMaterial(node, 'exterior')
43
+
44
+ return {
45
+ interiorMaterial: nextInterior.material,
46
+ interiorMaterialPreset: nextInterior.materialPreset,
47
+ exteriorMaterial: nextExterior.material,
48
+ exteriorMaterialPreset: nextExterior.materialPreset,
49
+ material: undefined,
50
+ materialPreset: undefined,
51
+ }
52
+ }
53
+
23
54
  export function WallPanel() {
24
55
  const selectedId = useViewer((s) => s.selection.selectedIds[0])
25
56
  const setSelection = useViewer((s) => s.setSelection)
26
57
  const updateNode = useScene((s) => s.updateNode)
27
58
  const setMovingNode = useEditor((s) => s.setMovingNode)
28
59
  const setCurvingWall = useEditor((s) => s.setCurvingWall)
60
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
29
61
 
30
62
  const node = useScene((s) =>
31
63
  selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -56,6 +88,35 @@ export function WallPanel() {
56
88
  [selectedId, updateNode],
57
89
  )
58
90
 
91
+ const effectiveInteriorMaterial = useMemo(
92
+ () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
93
+ [node],
94
+ )
95
+ const effectiveExteriorMaterial = useMemo(
96
+ () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
97
+ [node],
98
+ )
99
+ const surfaceMaterialsMatch = useMemo(
100
+ () =>
101
+ getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
102
+ getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
103
+ [effectiveExteriorMaterial, effectiveInteriorMaterial],
104
+ )
105
+ const materialTargetSide =
106
+ selectedMaterialTarget &&
107
+ selectedMaterialTarget.nodeId === node?.id &&
108
+ (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
109
+ ? selectedMaterialTarget.role
110
+ : null
111
+ const materialPickerValue =
112
+ materialTargetSide === 'interior'
113
+ ? effectiveInteriorMaterial
114
+ : materialTargetSide === 'exterior'
115
+ ? effectiveExteriorMaterial
116
+ : surfaceMaterialsMatch
117
+ ? effectiveInteriorMaterial
118
+ : {}
119
+
59
120
  const handleUpdateLength = useCallback(
60
121
  (newLength: number) => {
61
122
  if (!node || newLength <= 0) return
@@ -79,6 +140,24 @@ export function WallPanel() {
79
140
  [node, handleUpdate],
80
141
  )
81
142
 
143
+ const handleMaterialPresetChange = useCallback(
144
+ (materialPreset: string) => {
145
+ if (!(node && materialTargetSide)) return
146
+ handleUpdate(
147
+ buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
148
+ )
149
+ },
150
+ [handleUpdate, materialTargetSide, node],
151
+ )
152
+
153
+ const handleCustomMaterialChange = useCallback(
154
+ (material: MaterialSchema) => {
155
+ if (!(node && materialTargetSide)) return
156
+ handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
157
+ },
158
+ [handleUpdate, materialTargetSide, node],
159
+ )
160
+
82
161
  const handleClose = useCallback(() => {
83
162
  setSelection({ selectedIds: [] })
84
163
  }, [setSelection])
@@ -160,6 +239,23 @@ export function WallPanel() {
160
239
  )}
161
240
  </PanelSection>
162
241
 
242
+ <PanelSection title="Material">
243
+ {materialTargetSide ? null : (
244
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
245
+ Click the wall face you want to edit. Materials now apply to one side at a time.
246
+ </div>
247
+ )}
248
+ <MaterialPicker
249
+ disabled={!materialTargetSide}
250
+ hideSideControl
251
+ nodeType="wall"
252
+ onChange={handleCustomMaterialChange}
253
+ onSelectMaterialPreset={handleMaterialPresetChange}
254
+ selectedMaterialPreset={materialPickerValue.materialPreset}
255
+ value={materialPickerValue.material}
256
+ />
257
+ </PanelSection>
258
+
163
259
  <PanelSection title="Actions">
164
260
  <ActionGroup>
165
261
  <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
12
12
  import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
13
  import { useCallback, useRef } from 'react'
14
14
  import { usePresetsAdapter } from '../../../contexts/presets-context'
15
- import { cn } from '../../../lib/utils'
16
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import { cn } from '../../../lib/utils'
17
17
  import useEditor from '../../../store/use-editor'
18
18
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
19
  import { MetricControl } from '../controls/metric-control'
@@ -129,7 +129,11 @@ export function WindowPanel() {
129
129
  if (liveNode?.type !== 'window') return
130
130
 
131
131
  if (
132
- !(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)
132
+ !(
133
+ previewRef.current &&
134
+ previewRef.current.id === selectedId &&
135
+ previewRef.current.key === key
136
+ )
133
137
  ) {
134
138
  previewRef.current = {
135
139
  id: selectedId as AnyNodeId,
@@ -299,7 +303,8 @@ export function WindowPanel() {
299
303
  const normRows = node.rowRatios.map((r) => r / rowSum)
300
304
  const isOpening = node.openingKind === 'opening'
301
305
  const openingShape = node.openingShape ?? 'rectangle'
302
- const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
306
+ const windowShape =
307
+ openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
303
308
  const openingRadiusMode = node.openingRadiusMode ?? 'all'
304
309
  const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
305
310
  const cornerRadius = node.cornerRadius ?? 0.15
@@ -340,7 +345,10 @@ export function WindowPanel() {
340
345
  nextUpdates.openingCornerRadii = nextRadii
341
346
  }
342
347
  } else {
343
- const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight))
348
+ const nextRadius = Math.min(
349
+ Math.max(cornerRadius, 0),
350
+ getMaxSharedWindowRadius(nextWidth, nextHeight),
351
+ )
344
352
  if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
345
353
  nextUpdates.cornerRadius = nextRadius
346
354
  }
@@ -597,7 +605,7 @@ export function WindowPanel() {
597
605
  />
598
606
  </PanelSection>
599
607
 
600
- {!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
608
+ {!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && (
601
609
  <PanelSection title="Corner Shape">
602
610
  <SegmentedControl
603
611
  onChange={(value) =>
@@ -144,7 +144,7 @@ export function NumberInput({
144
144
  }}
145
145
  />
146
146
  <div
147
- className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
147
+ className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
148
148
  >
149
149
  <div
150
150
  className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
File without changes
File without changes
File without changes
@@ -0,0 +1,330 @@
1
+ 'use client'
2
+
3
+ import type { AssetInput } from '@pascal-app/core'
4
+ import NextImage from 'next/image'
5
+ import { useEffect, useState } from 'react'
6
+ import { cn } from '../../../../../lib/utils'
7
+ import type { CatalogCategory } from '../../../../../store/use-editor'
8
+ import useEditor from '../../../../../store/use-editor'
9
+ import { furnishTools } from '../../../action-menu/furnish-tools'
10
+ import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
11
+ import { ItemCatalog } from '../../../item-catalog/item-catalog'
12
+
13
+ const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
14
+
15
+ export function ItemsPanel({
16
+ items,
17
+ onSearchChange,
18
+ searchResults,
19
+ leadingTile,
20
+ emptyState,
21
+ }: {
22
+ items?: AssetInput[]
23
+ /** Called when the search query changes (community edition uses this for server-side search) */
24
+ onSearchChange?: (query: string) => void
25
+ /** When non-null and search is active, these results bypass local filtering (server search results) */
26
+ searchResults?: AssetInput[] | null
27
+ /**
28
+ * Optional node rendered as the first grid cell, always visible. Used by the
29
+ * community edition to inject a "+ Generate with AI" tile.
30
+ */
31
+ leadingTile?: React.ReactNode
32
+ /**
33
+ * Optional node rendered when the grid has no items to show (empty category
34
+ * or no search results). Replaces the default "No results" message.
35
+ */
36
+ emptyState?: React.ReactNode
37
+ }) {
38
+ const mode = useEditor((s) => s.mode)
39
+ const catalogCategory = useEditor((s) => s.catalogCategory)
40
+ const setMode = useEditor((s) => s.setMode)
41
+ const setTool = useEditor((s) => s.setTool)
42
+ const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
43
+
44
+ const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
45
+ const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
46
+ // Library / Community / Mine. Default to Library so first-time users see
47
+ // the curated catalog rather than every uploaded item; clicking the chip
48
+ // again clears the filter (`null` = show everything).
49
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
50
+ const [search, setSearch] = useState('')
51
+ const isServerSearch = onSearchChange !== undefined
52
+ // True when server search is active but results haven't come back yet
53
+ const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
54
+
55
+ // Auto-select the first category when the panel mounts without one
56
+ useEffect(() => {
57
+ if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
58
+ setCatalogCategory(furnishTools[0]!.catalogCategory)
59
+ }
60
+ }, [catalogCategory, setCatalogCategory])
61
+
62
+ const activeCategory =
63
+ furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
64
+
65
+ function selectCategory(categoryId: CatalogCategory) {
66
+ setCatalogCategory(categoryId)
67
+ setTool('item')
68
+ setActivePlacementTag(null)
69
+ setActiveFunctionalTag(null)
70
+ setSearch('')
71
+ if (mode !== 'build') setMode('build')
72
+ }
73
+
74
+ // Compute tags for the current category (for filter chips)
75
+ const baseItems = items ?? CATALOG_ITEMS
76
+ // Apply the Library/Community/Mine filter before any category/tag work.
77
+ // Items that don't carry a source field (e.g. seeded built-in catalog
78
+ // entries from `CATALOG_ITEMS`) fall under "library".
79
+ //
80
+ // Community is broader than just other users' uploads: my own *published*
81
+ // items show up there too so I can preview my catalog the way other users
82
+ // see it. My drafts only appear under Mine.
83
+ const matchesSource = (item: AssetInput) => {
84
+ if (!activeSource) return true
85
+ const itemSource = item.source ?? 'library'
86
+ if (activeSource === 'mine') return itemSource === 'mine'
87
+ if (activeSource === 'library') return itemSource === 'library'
88
+ if (activeSource === 'community') {
89
+ if (itemSource === 'community') return true
90
+ if (itemSource === 'mine') return !item.isDraft
91
+ return false
92
+ }
93
+ return true
94
+ }
95
+ const sourceItems = baseItems.filter(matchesSource)
96
+ const categoryItems = sourceItems.filter(
97
+ (item) => item.category === activeCategory.catalogCategory,
98
+ )
99
+
100
+ // The three source chips are always shown so users can discover the
101
+ // filter even before they own any items. Selecting "Mine" with no
102
+ // matching items falls through to the empty/no-results state.
103
+ const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
104
+ { id: 'library', label: 'Library' },
105
+ { id: 'community', label: 'Community' },
106
+ { id: 'mine', label: 'Mine' },
107
+ ]
108
+ const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
109
+ const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
110
+ const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
111
+ const hasFilters = allTags.length > 1
112
+
113
+ const placementCount = (tag: string | null) =>
114
+ categoryItems.filter((item) => {
115
+ const tags = item.tags ?? []
116
+ if (tag !== null && !tags.includes(tag)) return false
117
+ if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
118
+ return true
119
+ }).length
120
+
121
+ const functionalCount = (tag: string) =>
122
+ categoryItems.filter((item) => {
123
+ const tags = item.tags ?? []
124
+ if (!tags.includes(tag)) return false
125
+ if (activePlacementTag && !tags.includes(activePlacementTag)) return false
126
+ return true
127
+ }).length
128
+
129
+ return (
130
+ <div className="flex h-full flex-col">
131
+ {/* Category tabs */}
132
+ <div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
133
+ {furnishTools.map((cat) => {
134
+ const isActive = activeCategory.catalogCategory === cat.catalogCategory
135
+ return (
136
+ <button
137
+ className={cn(
138
+ 'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
139
+ isActive
140
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
141
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
142
+ )}
143
+ key={cat.catalogCategory}
144
+ onClick={() => selectCategory(cat.catalogCategory)}
145
+ type="button"
146
+ >
147
+ <NextImage
148
+ alt={cat.label}
149
+ className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
150
+ height={28}
151
+ src={cat.iconSrc}
152
+ width={28}
153
+ />
154
+ <span className="font-medium text-[10px] leading-none">{cat.label}</span>
155
+ </button>
156
+ )
157
+ })}
158
+ </div>
159
+
160
+ {/* Search + filters (non-scrollable) */}
161
+ <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
162
+ <div className="flex items-center gap-1.5">
163
+ {/* Search and source filter take 50/50 of the row. `min-w-0` on
164
+ both sides lets each half shrink to fit when the panel narrows. */}
165
+ <input
166
+ className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
167
+ onChange={(e) => {
168
+ setSearch(e.target.value)
169
+ onSearchChange?.(e.target.value)
170
+ }}
171
+ placeholder="Search..."
172
+ type="text"
173
+ value={search}
174
+ />
175
+ {sourceChips.length > 0 && (
176
+ <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
177
+ {sourceChips.map((chip) => {
178
+ const isActive = activeSource === chip.id
179
+ return (
180
+ <button
181
+ className={cn(
182
+ 'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
183
+ isActive
184
+ ? 'bg-background text-foreground shadow-sm'
185
+ : 'text-muted-foreground hover:text-foreground',
186
+ )}
187
+ key={chip.id}
188
+ onClick={() => setActiveSource(isActive ? null : chip.id)}
189
+ type="button"
190
+ >
191
+ {chip.label}
192
+ </button>
193
+ )
194
+ })}
195
+ </div>
196
+ )}
197
+ </div>
198
+
199
+ {hasFilters && !search && !isServerSearch && (
200
+ <div className="flex flex-col gap-1.5">
201
+ {placementTags.length > 0 && (
202
+ <div className="flex flex-wrap gap-1">
203
+ <button
204
+ className={cn(
205
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
206
+ activePlacementTag === null
207
+ ? 'bg-blue-500 text-white'
208
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
209
+ )}
210
+ onClick={() => setActivePlacementTag(null)}
211
+ type="button"
212
+ >
213
+ All
214
+ </button>
215
+ {placementTags.map((tag) => {
216
+ const count = placementCount(tag)
217
+ const isActive = activePlacementTag === tag
218
+ const isEmpty = count === 0 && !isActive
219
+ return (
220
+ <button
221
+ className={cn(
222
+ 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
223
+ isActive
224
+ ? 'bg-blue-500 text-white'
225
+ : isEmpty
226
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
227
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
228
+ )}
229
+ disabled={isEmpty}
230
+ key={tag}
231
+ onClick={() => setActivePlacementTag(isActive ? null : tag)}
232
+ type="button"
233
+ >
234
+ {tag}
235
+ <span
236
+ className={cn(
237
+ 'text-[10px]',
238
+ isActive
239
+ ? 'text-blue-200'
240
+ : isEmpty
241
+ ? 'text-zinc-600'
242
+ : 'text-blue-500/70',
243
+ )}
244
+ >
245
+ {count}
246
+ </span>
247
+ </button>
248
+ )
249
+ })}
250
+ </div>
251
+ )}
252
+
253
+ {functionalTags.length > 0 && (
254
+ <div className="flex flex-wrap gap-1">
255
+ {functionalTags.map((tag) => {
256
+ const count = functionalCount(tag)
257
+ const isActive = activeFunctionalTag === tag
258
+ const isEmpty = count === 0 && !isActive
259
+ return (
260
+ <button
261
+ className={cn(
262
+ 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
263
+ isActive
264
+ ? 'bg-violet-500 text-white'
265
+ : isEmpty
266
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
267
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
268
+ )}
269
+ disabled={isEmpty}
270
+ key={tag}
271
+ onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
272
+ type="button"
273
+ >
274
+ {tag}
275
+ <span
276
+ className={cn(
277
+ 'text-[10px]',
278
+ isActive
279
+ ? 'text-violet-200'
280
+ : isEmpty
281
+ ? 'text-zinc-600'
282
+ : 'text-zinc-500/70',
283
+ )}
284
+ >
285
+ {count}
286
+ </span>
287
+ </button>
288
+ )
289
+ })}
290
+ </div>
291
+ )}
292
+ </div>
293
+ )}
294
+ </div>
295
+
296
+ {/* Item grid */}
297
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
298
+ {isSearchPending ? (
299
+ <div className="flex h-full items-center justify-center">
300
+ <div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
301
+ </div>
302
+ ) : isServerSearch && search && searchResults?.length === 0 ? (
303
+ (emptyState ?? (
304
+ <div className="flex h-full items-center justify-center text-muted-foreground text-xs">
305
+ No results for &ldquo;{search}&rdquo;
306
+ </div>
307
+ ))
308
+ ) : (
309
+ <ItemCatalog
310
+ activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
311
+ activePlacementTag={isServerSearch ? null : activePlacementTag}
312
+ category={activeCategory.catalogCategory}
313
+ emptyState={emptyState}
314
+ items={activeSource && items ? items.filter(matchesSource) : items}
315
+ key={activeCategory.catalogCategory}
316
+ leadingTile={leadingTile}
317
+ overrideItems={
318
+ isServerSearch && search
319
+ ? activeSource && searchResults
320
+ ? searchResults.filter(matchesSource)
321
+ : (searchResults ?? undefined)
322
+ : undefined
323
+ }
324
+ search={isServerSearch ? '' : search}
325
+ />
326
+ )}
327
+ </div>
328
+ </div>
329
+ )
330
+ }
@@ -3,9 +3,9 @@ import {
3
3
  type AnyNodeId,
4
4
  type BuildingNode,
5
5
  emitter,
6
- GuideNode,
6
+ type GuideNode,
7
7
  LevelNode,
8
- ScanNode,
8
+ type ScanNode,
9
9
  type SiteNode,
10
10
  useScene,
11
11
  type ZoneNode,
@@ -32,14 +32,12 @@ import {
32
32
  PopoverContent,
33
33
  PopoverTrigger,
34
34
  } from './../../../../../components/ui/primitives/popover'
35
- import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
36
- import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
37
-
38
35
  import {
39
36
  buildLevelDuplicateCreateOps,
40
37
  type LevelDuplicatePreset,
41
38
  } from './../../../../../lib/level-duplication'
42
-
39
+ import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
40
+ import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
43
41
  import { cn } from './../../../../../lib/utils'
44
42
  import useEditor from './../../../../../store/use-editor'
45
43
  import { useUploadStore } from '../../../../../store/use-upload'
@@ -50,13 +50,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
50
50
  expanded={false}
51
51
  hasChildren={false}
52
52
  icon={
53
- <Image
54
- alt=""
55
- className="object-contain"
56
- height={14}
57
- src="/icons/site.png"
58
- width={14}
59
- />
53
+ <Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
60
54
  }
61
55
  isHovered={isHovered}
62
56
  isLast={isLast}
@@ -82,7 +82,9 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
82
82
 
83
83
  switch (nodeType) {
84
84
  case 'building':
85
- return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
85
+ return (
86
+ <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
87
+ )
86
88
  case 'ceiling':
87
89
  return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
88
90
  case 'column':
@@ -44,7 +44,9 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
44
44
  depth={depth}
45
45
  expanded={false}
46
46
  hasChildren={false}
47
- icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
47
+ icon={
48
+ <ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />
49
+ }
48
50
  isHovered={isHovered}
49
51
  isLast={isLast}
50
52
  isSelected={isSelected}
@@ -41,7 +41,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
41
41
  className={cn(
42
42
  'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
43
43
  isSelected
44
- ? 'border-neutral-200/60 bg-white text-foreground shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
44
+ ? 'border-neutral-200/60 bg-white text-foreground shadow-elevation-0 ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
45
45
  : 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
46
46
  )}
47
47
  onClick={handleClick}
@@ -17,7 +17,7 @@ const sliderVariants = cva(
17
17
  [&_[data-slot=slider-track]]:border
18
18
  [&_[data-slot=slider-track]]:border-neutral-300
19
19
  [&_[data-slot=slider-track]]:bg-white/50
20
- [&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]
20
+ [&_[data-slot=slider-track]]:shadow-elevation-0
21
21
  [&_[data-slot=slider-track]]:ring-1
22
22
  [&_[data-slot=slider-track]]:ring-white
23
23
  [&_[data-slot=slider-track]]:ring-inset
File without changes
File without changes