diff --git a/bun.lock b/bun.lock index a3989b630..86a7bc1f1 100644 --- a/bun.lock +++ b/bun.lock @@ -211,7 +211,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.2.0", + "version": "0.3.0", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8434d35e..fa8a04549 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -99,6 +99,10 @@ const assetSchema = z.object({ dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), tags: z.array(z.string()).optional(), + // Function-axis tag slugs from the taxonomy. Drives the hierarchical + // Items-tab browse: a tree node matches when any of its descendant slugs + // appears here. Absent for the seeded built-in catalog. + functionTags: z.array(z.string()).optional(), // These are "Corrective" transforms to normalize the GLB offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx b/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx new file mode 100644 index 000000000..b26707897 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx @@ -0,0 +1,243 @@ +'use client' + +import type { AssetInput } from '@pascal-app/core' +import NextImage from 'next/image' +import { useMemo, useState } from 'react' +import { cn } from '../../../../../lib/utils' +import { ItemCatalog } from '../../../item-catalog/item-catalog' + +/** A function-axis taxonomy node, assembled into a tree by the embedder. */ +export type FunctionTreeNode = { + slug: string + name: string + iconUrl?: string | null + children: FunctionTreeNode[] +} + +const SOURCE_CHIPS: Array<{ id: NonNullable; label: string }> = [ + { id: 'library', label: 'Library' }, + { id: 'community', label: 'Community' }, + { id: 'mine', label: 'Mine' }, +] + +/** Every slug at or below `node`, so a non-leaf selection matches descendants. */ +function descendantSlugs(node: FunctionTreeNode): Set { + const out = new Set() + const walk = (n: FunctionTreeNode) => { + out.add(n.slug) + for (const child of n.children) walk(child) + } + walk(node) + return out +} + +function itemFunctionSlugs(item: AssetInput): string[] { + if (item.functionTags && item.functionTags.length > 0) return item.functionTags + return item.category ? [item.category] : [] +} + +/** + * DB-driven hierarchical Items browse. Roots render as the category tab bar; + * a selected root with children exposes those children as a secondary chip + * row. Selecting any node shows items tagged with that node or any descendant. + * Library / Community / Mine narrows by source on top of the tree selection. + */ +export function FunctionTreePanel({ + functionTree, + items, + onSearchChange, + searchResults, + leadingTile, + emptyState, +}: { + functionTree: FunctionTreeNode[] + items?: AssetInput[] + onSearchChange?: (query: string) => void + searchResults?: AssetInput[] | null + leadingTile?: React.ReactNode + emptyState?: React.ReactNode +}) { + const [activeRootSlug, setActiveRootSlug] = useState( + functionTree[0]?.slug ?? null, + ) + const [activeChildSlug, setActiveChildSlug] = useState(null) + const [activeSource, setActiveSource] = useState('library') + const [search, setSearch] = useState('') + + const isServerSearch = onSearchChange !== undefined + const isSearchPending = isServerSearch && search.length > 0 && searchResults === null + + const activeRoot = functionTree.find((n) => n.slug === activeRootSlug) ?? functionTree[0] + const activeNode = + (activeChildSlug && activeRoot?.children.find((c) => c.slug === activeChildSlug)) || activeRoot + + const matchesSource = (item: AssetInput) => { + if (!activeSource) return true + const itemSource = item.source ?? 'library' + if (activeSource === 'mine') return itemSource === 'mine' + if (activeSource === 'library') return itemSource === 'library' + if (activeSource === 'community') { + if (itemSource === 'community') return true + if (itemSource === 'mine') return !item.isDraft + return false + } + return true + } + + const treeItems = useMemo(() => { + const base = items ?? [] + if (!activeNode) return base.filter(matchesSource) + const slugs = descendantSlugs(activeNode) + return base.filter( + (item) => matchesSource(item) && itemFunctionSlugs(item).some((s) => slugs.has(s)), + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, activeNode, activeSource]) + + const searchItems = useMemo(() => { + if (!(isServerSearch && search && searchResults)) return null + return activeSource ? searchResults.filter(matchesSource) : searchResults + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isServerSearch, search, searchResults, activeSource]) + + function selectRoot(slug: string) { + setActiveRootSlug(slug) + setActiveChildSlug(null) + setSearch('') + onSearchChange?.('') + } + + return ( +
+ {/* Root nodes as category tabs */} +
+ {functionTree.map((root) => { + const isActive = activeRoot?.slug === root.slug + return ( + + ) + })} +
+ + {/* Search + source filter */} +
+
+ { + setSearch(e.target.value) + onSearchChange?.(e.target.value) + }} + placeholder="Search..." + type="text" + value={search} + /> +
+ {SOURCE_CHIPS.map((chip) => { + const isActive = activeSource === chip.id + return ( + + ) + })} +
+
+ + {/* Child nodes of the active root as a secondary chip row */} + {!search && activeRoot && activeRoot.children.length > 0 && ( +
+ + {activeRoot.children.map((child) => { + const isActive = activeChildSlug === child.slug + return ( + + ) + })} +
+ )} +
+ + {/* Item grid */} +
+ {isSearchPending ? ( +
+
+
+ ) : isServerSearch && search && searchResults?.length === 0 ? ( + (emptyState ?? ( +
+ No results for “{search}” +
+ )) + ) : ( + + )} +
+
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx index e549a2b78..e0ec03445 100644 --- a/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx @@ -9,6 +9,7 @@ import useEditor from '../../../../../store/use-editor' import { furnishTools } from '../../../action-menu/furnish-tools' import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items' import { ItemCatalog } from '../../../item-catalog/item-catalog' +import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel' const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop']) @@ -18,6 +19,7 @@ export function ItemsPanel({ searchResults, leadingTile, emptyState, + functionTree, }: { items?: AssetInput[] /** Called when the search query changes (community edition uses this for server-side search) */ @@ -34,6 +36,48 @@ export function ItemsPanel({ * or no search results). Replaces the default "No results" message. */ emptyState?: React.ReactNode + /** + * DB-driven function taxonomy. When provided, the panel renders the + * hierarchical tree browse instead of the legacy hardcoded category tabs. + */ + functionTree?: FunctionTreeNode[] +}) { + // When the embedder supplies a function taxonomy, the hierarchical browse + // replaces the legacy `furnishTools` category tabs entirely. + if (functionTree && functionTree.length > 0) { + return ( + + ) + } + + return +} + +function LegacyItemsPanel({ + items, + onSearchChange, + searchResults, + leadingTile, + emptyState, +}: { + items?: AssetInput[] + onSearchChange?: (query: string) => void + searchResults?: AssetInput[] | null + leadingTile?: React.ReactNode + emptyState?: React.ReactNode }) { const mode = useEditor((s) => s.mode) const catalogCategory = useEditor((s) => s.catalogCategory) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 86c14ef86..a935cfc71 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -143,6 +143,7 @@ export { Slider } from './components/ui/primitives/slider' export { SceneLoader } from './components/ui/scene-loader' export type { ExtraPanel } from './components/ui/sidebar/icon-rail' export { ItemsPanel } from './components/ui/sidebar/panels/items-panel' +export type { FunctionTreeNode } from './components/ui/sidebar/panels/items-panel/function-tree-panel' export { type ProjectVisibility, SettingsPanel,