@pascal-app/editor 0.7.0 → 0.9.1
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.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AssetInput } from '@pascal-app/core'
|
|
4
|
+
import { Root as TooltipRoot } from '@radix-ui/react-tooltip'
|
|
5
|
+
import NextImage from 'next/image'
|
|
6
|
+
import { useMemo, useState } from 'react'
|
|
7
|
+
import { triggerSFX } from '../../../../../lib/sfx-bus'
|
|
8
|
+
import { cn } from '../../../../../lib/utils'
|
|
9
|
+
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
|
10
|
+
import {
|
|
11
|
+
TooltipContent,
|
|
12
|
+
TooltipProvider,
|
|
13
|
+
TooltipTrigger,
|
|
14
|
+
} from '../../../../../components/ui/primitives/tooltip'
|
|
15
|
+
|
|
16
|
+
/** A function-axis taxonomy node, assembled into a tree by the embedder. */
|
|
17
|
+
export type FunctionTreeNode = {
|
|
18
|
+
slug: string
|
|
19
|
+
name: string
|
|
20
|
+
iconUrl?: string | null
|
|
21
|
+
children: FunctionTreeNode[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
|
|
25
|
+
{ id: 'library', label: 'Library' },
|
|
26
|
+
{ id: 'community', label: 'Community' },
|
|
27
|
+
{ id: 'mine', label: 'Mine' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/** Every slug at or below `node`, so a non-leaf selection matches descendants. */
|
|
31
|
+
function descendantSlugs(node: FunctionTreeNode): Set<string> {
|
|
32
|
+
const out = new Set<string>()
|
|
33
|
+
const walk = (n: FunctionTreeNode) => {
|
|
34
|
+
out.add(n.slug)
|
|
35
|
+
for (const child of n.children) walk(child)
|
|
36
|
+
}
|
|
37
|
+
walk(node)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function itemFunctionSlugs(item: AssetInput): string[] {
|
|
42
|
+
if (item.functionTags && item.functionTags.length > 0) return item.functionTags
|
|
43
|
+
return item.category ? [item.category] : []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* DB-driven hierarchical Items browse. Roots render as the category tab bar;
|
|
48
|
+
* a selected root with children exposes those children as a secondary chip
|
|
49
|
+
* row. Selecting any node shows items tagged with that node or any descendant.
|
|
50
|
+
* Library / Community / Mine narrows by source on top of the tree selection.
|
|
51
|
+
*/
|
|
52
|
+
export function FunctionTreePanel({
|
|
53
|
+
functionTree,
|
|
54
|
+
items,
|
|
55
|
+
onSearchChange,
|
|
56
|
+
searchResults,
|
|
57
|
+
leadingTile,
|
|
58
|
+
emptyState,
|
|
59
|
+
}: {
|
|
60
|
+
functionTree: FunctionTreeNode[]
|
|
61
|
+
items?: AssetInput[]
|
|
62
|
+
onSearchChange?: (query: string) => void
|
|
63
|
+
searchResults?: AssetInput[] | null
|
|
64
|
+
leadingTile?: React.ReactNode
|
|
65
|
+
emptyState?: React.ReactNode
|
|
66
|
+
}) {
|
|
67
|
+
const [activeRootSlug, setActiveRootSlug] = useState<string | null>(
|
|
68
|
+
functionTree[0]?.slug ?? null,
|
|
69
|
+
)
|
|
70
|
+
const [activeChildSlug, setActiveChildSlug] = useState<string | null>(null)
|
|
71
|
+
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
|
|
72
|
+
const [search, setSearch] = useState('')
|
|
73
|
+
|
|
74
|
+
const isServerSearch = onSearchChange !== undefined
|
|
75
|
+
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
|
|
76
|
+
|
|
77
|
+
const activeRoot = functionTree.find((n) => n.slug === activeRootSlug) ?? functionTree[0]
|
|
78
|
+
const activeNode =
|
|
79
|
+
(activeChildSlug && activeRoot?.children.find((c) => c.slug === activeChildSlug)) || activeRoot
|
|
80
|
+
|
|
81
|
+
const matchesSource = (item: AssetInput) => {
|
|
82
|
+
if (!activeSource) return true
|
|
83
|
+
const itemSource = item.source ?? 'library'
|
|
84
|
+
if (activeSource === 'mine') return itemSource === 'mine'
|
|
85
|
+
if (activeSource === 'library') return itemSource === 'library'
|
|
86
|
+
if (activeSource === 'community') {
|
|
87
|
+
if (itemSource === 'community') return true
|
|
88
|
+
if (itemSource === 'mine') return !item.isDraft
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const treeItems = useMemo(() => {
|
|
95
|
+
const base = items ?? []
|
|
96
|
+
if (!activeNode) return base.filter(matchesSource)
|
|
97
|
+
const slugs = descendantSlugs(activeNode)
|
|
98
|
+
return base.filter(
|
|
99
|
+
(item) => matchesSource(item) && itemFunctionSlugs(item).some((s) => slugs.has(s)),
|
|
100
|
+
)
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [items, activeNode, activeSource])
|
|
103
|
+
|
|
104
|
+
const searchItems = useMemo(() => {
|
|
105
|
+
if (!(isServerSearch && search && searchResults)) return null
|
|
106
|
+
return activeSource ? searchResults.filter(matchesSource) : searchResults
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
}, [isServerSearch, search, searchResults, activeSource])
|
|
109
|
+
|
|
110
|
+
function selectRoot(slug: string) {
|
|
111
|
+
setActiveRootSlug(slug)
|
|
112
|
+
setActiveChildSlug(null)
|
|
113
|
+
setSearch('')
|
|
114
|
+
onSearchChange?.('')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex h-full flex-col">
|
|
119
|
+
{/* Root nodes as a category grid — icon when available, otherwise a
|
|
120
|
+
two-letter abbreviation, with the full name in a hover tooltip.
|
|
121
|
+
Mirrors the Build tab's tile grid so the two panels read the same. */}
|
|
122
|
+
<TooltipProvider delayDuration={0} disableHoverableContent>
|
|
123
|
+
<div className="grid shrink-0 grid-cols-5 gap-1.5 border-border/70 border-b p-2">
|
|
124
|
+
{functionTree.map((root) => {
|
|
125
|
+
const isActive = activeRoot?.slug === root.slug
|
|
126
|
+
return (
|
|
127
|
+
<TooltipRoot key={root.slug}>
|
|
128
|
+
<TooltipTrigger asChild>
|
|
129
|
+
<button
|
|
130
|
+
className={cn(
|
|
131
|
+
'relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
|
|
132
|
+
isActive
|
|
133
|
+
? 'bg-primary/10 ring-1 ring-primary/50'
|
|
134
|
+
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
|
135
|
+
)}
|
|
136
|
+
onClick={() => {
|
|
137
|
+
triggerSFX('sfx:menu-click')
|
|
138
|
+
selectRoot(root.slug)
|
|
139
|
+
}}
|
|
140
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
141
|
+
type="button"
|
|
142
|
+
>
|
|
143
|
+
{root.iconUrl ? (
|
|
144
|
+
<NextImage
|
|
145
|
+
alt={root.name}
|
|
146
|
+
className="size-7 object-contain"
|
|
147
|
+
height={28}
|
|
148
|
+
src={root.iconUrl}
|
|
149
|
+
width={28}
|
|
150
|
+
/>
|
|
151
|
+
) : (
|
|
152
|
+
<span className="font-semibold text-muted-foreground text-xs uppercase">
|
|
153
|
+
{root.name.slice(0, 2)}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
157
|
+
</TooltipTrigger>
|
|
158
|
+
<TooltipContent className="icon-grid-tooltip pointer-events-none" side="top">
|
|
159
|
+
{root.name}
|
|
160
|
+
</TooltipContent>
|
|
161
|
+
</TooltipRoot>
|
|
162
|
+
)
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
</TooltipProvider>
|
|
166
|
+
|
|
167
|
+
{/* Search + source filter */}
|
|
168
|
+
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
|
169
|
+
<div className="flex items-center gap-1.5">
|
|
170
|
+
<input
|
|
171
|
+
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"
|
|
172
|
+
onChange={(e) => {
|
|
173
|
+
setSearch(e.target.value)
|
|
174
|
+
onSearchChange?.(e.target.value)
|
|
175
|
+
}}
|
|
176
|
+
placeholder="Search..."
|
|
177
|
+
type="text"
|
|
178
|
+
value={search}
|
|
179
|
+
/>
|
|
180
|
+
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
|
181
|
+
{SOURCE_CHIPS.map((chip) => {
|
|
182
|
+
const isActive = activeSource === chip.id
|
|
183
|
+
return (
|
|
184
|
+
<button
|
|
185
|
+
className={cn(
|
|
186
|
+
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
|
|
187
|
+
isActive
|
|
188
|
+
? 'bg-background text-foreground shadow-sm'
|
|
189
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
190
|
+
)}
|
|
191
|
+
key={chip.id}
|
|
192
|
+
onClick={() => setActiveSource(isActive ? null : chip.id)}
|
|
193
|
+
type="button"
|
|
194
|
+
>
|
|
195
|
+
{chip.label}
|
|
196
|
+
</button>
|
|
197
|
+
)
|
|
198
|
+
})}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Child nodes of the active root as a secondary chip row */}
|
|
203
|
+
{!search && activeRoot && activeRoot.children.length > 0 && (
|
|
204
|
+
<div className="flex flex-wrap gap-1">
|
|
205
|
+
<button
|
|
206
|
+
className={cn(
|
|
207
|
+
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
|
208
|
+
activeChildSlug === null
|
|
209
|
+
? 'bg-violet-500 text-white'
|
|
210
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
211
|
+
)}
|
|
212
|
+
onClick={() => setActiveChildSlug(null)}
|
|
213
|
+
type="button"
|
|
214
|
+
>
|
|
215
|
+
All
|
|
216
|
+
</button>
|
|
217
|
+
{activeRoot.children.map((child) => {
|
|
218
|
+
const isActive = activeChildSlug === child.slug
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
className={cn(
|
|
222
|
+
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs capitalize transition-colors',
|
|
223
|
+
isActive
|
|
224
|
+
? 'bg-violet-500 text-white'
|
|
225
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
226
|
+
)}
|
|
227
|
+
key={child.slug}
|
|
228
|
+
onClick={() => setActiveChildSlug(isActive ? null : child.slug)}
|
|
229
|
+
type="button"
|
|
230
|
+
>
|
|
231
|
+
{child.name}
|
|
232
|
+
</button>
|
|
233
|
+
)
|
|
234
|
+
})}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Item grid */}
|
|
240
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
241
|
+
{isSearchPending ? (
|
|
242
|
+
<div className="flex h-full items-center justify-center">
|
|
243
|
+
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
|
|
244
|
+
</div>
|
|
245
|
+
) : isServerSearch && search && searchResults?.length === 0 ? (
|
|
246
|
+
(emptyState ?? (
|
|
247
|
+
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
|
|
248
|
+
No results for “{search}”
|
|
249
|
+
</div>
|
|
250
|
+
))
|
|
251
|
+
) : (
|
|
252
|
+
<ItemCatalog
|
|
253
|
+
category={'furnish' as never}
|
|
254
|
+
emptyState={emptyState}
|
|
255
|
+
key={activeNode?.slug ?? 'all'}
|
|
256
|
+
leadingTile={leadingTile}
|
|
257
|
+
overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
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 { triggerSFX } from '../../../../../lib/sfx-bus'
|
|
7
|
+
import { cn } from '../../../../../lib/utils'
|
|
8
|
+
import type { CatalogCategory } from '../../../../../store/use-editor'
|
|
9
|
+
import useEditor from '../../../../../store/use-editor'
|
|
10
|
+
import { furnishTools } from '../../../action-menu/furnish-tools'
|
|
11
|
+
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
|
|
12
|
+
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
|
13
|
+
import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel'
|
|
14
|
+
|
|
15
|
+
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
|
16
|
+
|
|
17
|
+
export function ItemsPanel({
|
|
18
|
+
items,
|
|
19
|
+
onSearchChange,
|
|
20
|
+
searchResults,
|
|
21
|
+
leadingTile,
|
|
22
|
+
emptyState,
|
|
23
|
+
functionTree,
|
|
24
|
+
showSourceFilter = true,
|
|
25
|
+
showTagFilters = true,
|
|
26
|
+
}: {
|
|
27
|
+
items?: AssetInput[]
|
|
28
|
+
/** Called when the search query changes (community edition uses this for server-side search) */
|
|
29
|
+
onSearchChange?: (query: string) => void
|
|
30
|
+
/** When non-null and search is active, these results bypass local filtering (server search results) */
|
|
31
|
+
searchResults?: AssetInput[] | null
|
|
32
|
+
/**
|
|
33
|
+
* Optional node rendered as the first grid cell, always visible. Used by the
|
|
34
|
+
* community edition to inject a "+ Generate with AI" tile.
|
|
35
|
+
*/
|
|
36
|
+
leadingTile?: React.ReactNode
|
|
37
|
+
/**
|
|
38
|
+
* Optional node rendered when the grid has no items to show (empty category
|
|
39
|
+
* or no search results). Replaces the default "No results" message.
|
|
40
|
+
*/
|
|
41
|
+
emptyState?: React.ReactNode
|
|
42
|
+
/**
|
|
43
|
+
* DB-driven function taxonomy. When provided, the panel renders the
|
|
44
|
+
* hierarchical tree browse instead of the legacy hardcoded category tabs.
|
|
45
|
+
*/
|
|
46
|
+
functionTree?: FunctionTreeNode[]
|
|
47
|
+
/**
|
|
48
|
+
* Library/Community/Mine source chips. The open-source editor has no
|
|
49
|
+
* uploaded items (only the built-in catalog), so it hides these.
|
|
50
|
+
*/
|
|
51
|
+
showSourceFilter?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Placement/functional tag filter chips under the search row. The
|
|
54
|
+
* open-source editor hides these to keep the panel to plain categories.
|
|
55
|
+
*/
|
|
56
|
+
showTagFilters?: boolean
|
|
57
|
+
}) {
|
|
58
|
+
// When the embedder supplies a function taxonomy, the hierarchical browse
|
|
59
|
+
// replaces the legacy `furnishTools` category tabs entirely.
|
|
60
|
+
if (functionTree && functionTree.length > 0) {
|
|
61
|
+
return (
|
|
62
|
+
<FunctionTreePanel
|
|
63
|
+
emptyState={emptyState}
|
|
64
|
+
functionTree={functionTree}
|
|
65
|
+
items={items}
|
|
66
|
+
leadingTile={leadingTile}
|
|
67
|
+
onSearchChange={onSearchChange}
|
|
68
|
+
searchResults={searchResults}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return <LegacyItemsPanel
|
|
74
|
+
emptyState={emptyState}
|
|
75
|
+
items={items}
|
|
76
|
+
leadingTile={leadingTile}
|
|
77
|
+
onSearchChange={onSearchChange}
|
|
78
|
+
searchResults={searchResults}
|
|
79
|
+
showSourceFilter={showSourceFilter}
|
|
80
|
+
showTagFilters={showTagFilters}
|
|
81
|
+
/>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function LegacyItemsPanel({
|
|
85
|
+
items,
|
|
86
|
+
onSearchChange,
|
|
87
|
+
searchResults,
|
|
88
|
+
leadingTile,
|
|
89
|
+
emptyState,
|
|
90
|
+
showSourceFilter = true,
|
|
91
|
+
showTagFilters = true,
|
|
92
|
+
}: {
|
|
93
|
+
items?: AssetInput[]
|
|
94
|
+
onSearchChange?: (query: string) => void
|
|
95
|
+
searchResults?: AssetInput[] | null
|
|
96
|
+
leadingTile?: React.ReactNode
|
|
97
|
+
emptyState?: React.ReactNode
|
|
98
|
+
showSourceFilter?: boolean
|
|
99
|
+
showTagFilters?: boolean
|
|
100
|
+
}) {
|
|
101
|
+
const mode = useEditor((s) => s.mode)
|
|
102
|
+
const catalogCategory = useEditor((s) => s.catalogCategory)
|
|
103
|
+
const setMode = useEditor((s) => s.setMode)
|
|
104
|
+
const setTool = useEditor((s) => s.setTool)
|
|
105
|
+
const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
|
|
106
|
+
|
|
107
|
+
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
|
108
|
+
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
|
109
|
+
// Library / Community / Mine. Default to Library so first-time users see
|
|
110
|
+
// the curated catalog rather than every uploaded item; clicking the chip
|
|
111
|
+
// again clears the filter (`null` = show everything). With the chips hidden
|
|
112
|
+
// there is nothing to filter by, so start unfiltered.
|
|
113
|
+
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>(
|
|
114
|
+
showSourceFilter ? 'library' : null,
|
|
115
|
+
)
|
|
116
|
+
const [search, setSearch] = useState('')
|
|
117
|
+
const isServerSearch = onSearchChange !== undefined
|
|
118
|
+
// True when server search is active but results haven't come back yet
|
|
119
|
+
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
|
|
120
|
+
|
|
121
|
+
// Auto-select the first category when the panel mounts without one
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
|
|
124
|
+
setCatalogCategory(furnishTools[0]!.catalogCategory)
|
|
125
|
+
}
|
|
126
|
+
}, [catalogCategory, setCatalogCategory])
|
|
127
|
+
|
|
128
|
+
const activeCategory =
|
|
129
|
+
furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
|
|
130
|
+
|
|
131
|
+
function selectCategory(categoryId: CatalogCategory) {
|
|
132
|
+
setCatalogCategory(categoryId)
|
|
133
|
+
setTool('item')
|
|
134
|
+
setActivePlacementTag(null)
|
|
135
|
+
setActiveFunctionalTag(null)
|
|
136
|
+
setSearch('')
|
|
137
|
+
if (mode !== 'build') setMode('build')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Compute tags for the current category (for filter chips)
|
|
141
|
+
const baseItems = items ?? CATALOG_ITEMS
|
|
142
|
+
// Apply the Library/Community/Mine filter before any category/tag work.
|
|
143
|
+
// Items that don't carry a source field (e.g. seeded built-in catalog
|
|
144
|
+
// entries from `CATALOG_ITEMS`) fall under "library".
|
|
145
|
+
//
|
|
146
|
+
// Community is broader than just other users' uploads: my own *published*
|
|
147
|
+
// items show up there too so I can preview my catalog the way other users
|
|
148
|
+
// see it. My drafts only appear under Mine.
|
|
149
|
+
const matchesSource = (item: AssetInput) => {
|
|
150
|
+
if (!activeSource) return true
|
|
151
|
+
const itemSource = item.source ?? 'library'
|
|
152
|
+
if (activeSource === 'mine') return itemSource === 'mine'
|
|
153
|
+
if (activeSource === 'library') return itemSource === 'library'
|
|
154
|
+
if (activeSource === 'community') {
|
|
155
|
+
if (itemSource === 'community') return true
|
|
156
|
+
if (itemSource === 'mine') return !item.isDraft
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
const sourceItems = baseItems.filter(matchesSource)
|
|
162
|
+
const categoryItems = sourceItems.filter(
|
|
163
|
+
(item) => item.category === activeCategory.catalogCategory,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// The three source chips are always shown so users can discover the
|
|
167
|
+
// filter even before they own any items. Selecting "Mine" with no
|
|
168
|
+
// matching items falls through to the empty/no-results state.
|
|
169
|
+
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
|
|
170
|
+
{ id: 'library', label: 'Library' },
|
|
171
|
+
{ id: 'community', label: 'Community' },
|
|
172
|
+
{ id: 'mine', label: 'Mine' },
|
|
173
|
+
]
|
|
174
|
+
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
|
175
|
+
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
|
176
|
+
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
|
177
|
+
const hasFilters = showTagFilters && allTags.length > 1
|
|
178
|
+
|
|
179
|
+
const placementCount = (tag: string | null) =>
|
|
180
|
+
categoryItems.filter((item) => {
|
|
181
|
+
const tags = item.tags ?? []
|
|
182
|
+
if (tag !== null && !tags.includes(tag)) return false
|
|
183
|
+
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
184
|
+
return true
|
|
185
|
+
}).length
|
|
186
|
+
|
|
187
|
+
const functionalCount = (tag: string) =>
|
|
188
|
+
categoryItems.filter((item) => {
|
|
189
|
+
const tags = item.tags ?? []
|
|
190
|
+
if (!tags.includes(tag)) return false
|
|
191
|
+
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
192
|
+
return true
|
|
193
|
+
}).length
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex h-full flex-col">
|
|
197
|
+
{/* Category tabs */}
|
|
198
|
+
<div className="flex shrink-0 flex-wrap gap-1 border-border/70 border-b p-2">
|
|
199
|
+
{furnishTools.map((cat) => {
|
|
200
|
+
const isActive = activeCategory.catalogCategory === cat.catalogCategory
|
|
201
|
+
return (
|
|
202
|
+
<button
|
|
203
|
+
className={cn(
|
|
204
|
+
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
|
|
205
|
+
isActive
|
|
206
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
207
|
+
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
|
|
208
|
+
)}
|
|
209
|
+
key={cat.catalogCategory}
|
|
210
|
+
onClick={() => {
|
|
211
|
+
triggerSFX('sfx:menu-click')
|
|
212
|
+
selectCategory(cat.catalogCategory)
|
|
213
|
+
}}
|
|
214
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
215
|
+
type="button"
|
|
216
|
+
>
|
|
217
|
+
<NextImage
|
|
218
|
+
alt={cat.label}
|
|
219
|
+
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
|
|
220
|
+
height={28}
|
|
221
|
+
src={cat.iconSrc}
|
|
222
|
+
width={28}
|
|
223
|
+
/>
|
|
224
|
+
<span className="font-medium text-[10px] leading-none">{cat.label}</span>
|
|
225
|
+
</button>
|
|
226
|
+
)
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Search + filters (non-scrollable) */}
|
|
231
|
+
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
|
232
|
+
<div className="flex items-center gap-1.5">
|
|
233
|
+
{/* Search and source filter take 50/50 of the row. `min-w-0` on
|
|
234
|
+
both sides lets each half shrink to fit when the panel narrows.
|
|
235
|
+
With the source chips hidden, search spans the full row. */}
|
|
236
|
+
<input
|
|
237
|
+
className={cn(
|
|
238
|
+
'min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none',
|
|
239
|
+
showSourceFilter ? 'w-1/2' : 'w-full',
|
|
240
|
+
)}
|
|
241
|
+
onChange={(e) => {
|
|
242
|
+
setSearch(e.target.value)
|
|
243
|
+
onSearchChange?.(e.target.value)
|
|
244
|
+
}}
|
|
245
|
+
placeholder="Search..."
|
|
246
|
+
type="text"
|
|
247
|
+
value={search}
|
|
248
|
+
/>
|
|
249
|
+
{showSourceFilter && sourceChips.length > 0 && (
|
|
250
|
+
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
|
251
|
+
{sourceChips.map((chip) => {
|
|
252
|
+
const isActive = activeSource === chip.id
|
|
253
|
+
return (
|
|
254
|
+
<button
|
|
255
|
+
className={cn(
|
|
256
|
+
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
|
|
257
|
+
isActive
|
|
258
|
+
? 'bg-background text-foreground shadow-sm'
|
|
259
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
260
|
+
)}
|
|
261
|
+
key={chip.id}
|
|
262
|
+
onClick={() => setActiveSource(isActive ? null : chip.id)}
|
|
263
|
+
type="button"
|
|
264
|
+
>
|
|
265
|
+
{chip.label}
|
|
266
|
+
</button>
|
|
267
|
+
)
|
|
268
|
+
})}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{hasFilters && !search && !isServerSearch && (
|
|
274
|
+
<div className="flex flex-col gap-1.5">
|
|
275
|
+
{placementTags.length > 0 && (
|
|
276
|
+
<div className="flex flex-wrap gap-1">
|
|
277
|
+
<button
|
|
278
|
+
className={cn(
|
|
279
|
+
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
|
280
|
+
activePlacementTag === null
|
|
281
|
+
? 'bg-blue-500 text-white'
|
|
282
|
+
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
283
|
+
)}
|
|
284
|
+
onClick={() => setActivePlacementTag(null)}
|
|
285
|
+
type="button"
|
|
286
|
+
>
|
|
287
|
+
All
|
|
288
|
+
</button>
|
|
289
|
+
{placementTags.map((tag) => {
|
|
290
|
+
const count = placementCount(tag)
|
|
291
|
+
const isActive = activePlacementTag === tag
|
|
292
|
+
const isEmpty = count === 0 && !isActive
|
|
293
|
+
return (
|
|
294
|
+
<button
|
|
295
|
+
className={cn(
|
|
296
|
+
'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',
|
|
297
|
+
isActive
|
|
298
|
+
? 'bg-blue-500 text-white'
|
|
299
|
+
: isEmpty
|
|
300
|
+
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
301
|
+
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
302
|
+
)}
|
|
303
|
+
disabled={isEmpty}
|
|
304
|
+
key={tag}
|
|
305
|
+
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
|
306
|
+
type="button"
|
|
307
|
+
>
|
|
308
|
+
{tag}
|
|
309
|
+
<span
|
|
310
|
+
className={cn(
|
|
311
|
+
'text-[10px]',
|
|
312
|
+
isActive
|
|
313
|
+
? 'text-blue-200'
|
|
314
|
+
: isEmpty
|
|
315
|
+
? 'text-zinc-600'
|
|
316
|
+
: 'text-blue-500/70',
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
{count}
|
|
320
|
+
</span>
|
|
321
|
+
</button>
|
|
322
|
+
)
|
|
323
|
+
})}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{functionalTags.length > 0 && (
|
|
328
|
+
<div className="flex flex-wrap gap-1">
|
|
329
|
+
{functionalTags.map((tag) => {
|
|
330
|
+
const count = functionalCount(tag)
|
|
331
|
+
const isActive = activeFunctionalTag === tag
|
|
332
|
+
const isEmpty = count === 0 && !isActive
|
|
333
|
+
return (
|
|
334
|
+
<button
|
|
335
|
+
className={cn(
|
|
336
|
+
'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',
|
|
337
|
+
isActive
|
|
338
|
+
? 'bg-violet-500 text-white'
|
|
339
|
+
: isEmpty
|
|
340
|
+
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
341
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
342
|
+
)}
|
|
343
|
+
disabled={isEmpty}
|
|
344
|
+
key={tag}
|
|
345
|
+
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
|
346
|
+
type="button"
|
|
347
|
+
>
|
|
348
|
+
{tag}
|
|
349
|
+
<span
|
|
350
|
+
className={cn(
|
|
351
|
+
'text-[10px]',
|
|
352
|
+
isActive
|
|
353
|
+
? 'text-violet-200'
|
|
354
|
+
: isEmpty
|
|
355
|
+
? 'text-zinc-600'
|
|
356
|
+
: 'text-zinc-500/70',
|
|
357
|
+
)}
|
|
358
|
+
>
|
|
359
|
+
{count}
|
|
360
|
+
</span>
|
|
361
|
+
</button>
|
|
362
|
+
)
|
|
363
|
+
})}
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Item grid */}
|
|
371
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
372
|
+
{isSearchPending ? (
|
|
373
|
+
<div className="flex h-full items-center justify-center">
|
|
374
|
+
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
|
|
375
|
+
</div>
|
|
376
|
+
) : isServerSearch && search && searchResults?.length === 0 ? (
|
|
377
|
+
(emptyState ?? (
|
|
378
|
+
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
|
|
379
|
+
No results for “{search}”
|
|
380
|
+
</div>
|
|
381
|
+
))
|
|
382
|
+
) : (
|
|
383
|
+
<ItemCatalog
|
|
384
|
+
activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
|
|
385
|
+
activePlacementTag={isServerSearch ? null : activePlacementTag}
|
|
386
|
+
category={activeCategory.catalogCategory}
|
|
387
|
+
emptyState={emptyState}
|
|
388
|
+
items={activeSource && items ? items.filter(matchesSource) : items}
|
|
389
|
+
key={activeCategory.catalogCategory}
|
|
390
|
+
leadingTile={leadingTile}
|
|
391
|
+
overrideItems={
|
|
392
|
+
isServerSearch && search
|
|
393
|
+
? activeSource && searchResults
|
|
394
|
+
? searchResults.filter(matchesSource)
|
|
395
|
+
: (searchResults ?? undefined)
|
|
396
|
+
: undefined
|
|
397
|
+
}
|
|
398
|
+
search={isServerSearch ? '' : search}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
)
|
|
404
|
+
}
|