feat(editor): hierarchical function-tree Items browse (#345)
* feat(editor): hierarchical function-tree Items browse Add an optional DB-driven taxonomy browse to the Items panel. When the embedder supplies a `functionTree`, the panel renders roots as category tabs and child tags as a secondary chip row, filtering items by the selected node and any descendant slug; otherwise it falls back to the legacy hardcoded category path untouched. Adds `AssetInput.functionTags` so items can carry their function-axis tag slugs, and exports `FunctionTreeNode` for embedders to type the tree. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(editor): sync bun.lock (@pascal-app/mcp 0.3.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8a5f08b842
commit
a144502c04
@@ -211,7 +211,7 @@
|
|||||||
},
|
},
|
||||||
"packages/mcp": {
|
"packages/mcp": {
|
||||||
"name": "@pascal-app/mcp",
|
"name": "@pascal-app/mcp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pascal-mcp": "./dist/bin/pascal-mcp.js",
|
"pascal-mcp": "./dist/bin/pascal-mcp.js",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const assetSchema = z.object({
|
|||||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||||
tags: z.array(z.string()).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
|
// These are "Corrective" transforms to normalize the GLB
|
||||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
|||||||
@@ -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<AssetInput['source']>; 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<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
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<string | null>(
|
||||||
|
functionTree[0]?.slug ?? null,
|
||||||
|
)
|
||||||
|
const [activeChildSlug, setActiveChildSlug] = useState<string | null>(null)
|
||||||
|
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Root nodes as category tabs */}
|
||||||
|
<div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
|
||||||
|
{functionTree.map((root) => {
|
||||||
|
const isActive = activeRoot?.slug === root.slug
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={root.slug}
|
||||||
|
onClick={() => selectRoot(root.slug)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{root.iconUrl ? (
|
||||||
|
<NextImage
|
||||||
|
alt={root.name}
|
||||||
|
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
|
||||||
|
height={28}
|
||||||
|
src={root.iconUrl}
|
||||||
|
width={28}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/50 font-semibold text-[11px] uppercase">
|
||||||
|
{root.name.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-[10px] leading-none">{root.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + source filter */}
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
onSearchChange?.(e.target.value)
|
||||||
|
}}
|
||||||
|
placeholder="Search..."
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
||||||
|
{SOURCE_CHIPS.map((chip) => {
|
||||||
|
const isActive = activeSource === chip.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={chip.id}
|
||||||
|
onClick={() => setActiveSource(isActive ? null : chip.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Child nodes of the active root as a secondary chip row */}
|
||||||
|
{!search && activeRoot && activeRoot.children.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
||||||
|
activeChildSlug === null
|
||||||
|
? 'bg-violet-500 text-white'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveChildSlug(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{activeRoot.children.map((child) => {
|
||||||
|
const isActive = activeChildSlug === child.slug
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs capitalize transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-violet-500 text-white'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={child.slug}
|
||||||
|
onClick={() => setActiveChildSlug(isActive ? null : child.slug)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item grid */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{isSearchPending ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : isServerSearch && search && searchResults?.length === 0 ? (
|
||||||
|
(emptyState ?? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
|
||||||
|
No results for “{search}”
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<ItemCatalog
|
||||||
|
category={'furnish' as never}
|
||||||
|
emptyState={emptyState}
|
||||||
|
key={activeNode?.slug ?? 'all'}
|
||||||
|
leadingTile={leadingTile}
|
||||||
|
overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import useEditor from '../../../../../store/use-editor'
|
|||||||
import { furnishTools } from '../../../action-menu/furnish-tools'
|
import { furnishTools } from '../../../action-menu/furnish-tools'
|
||||||
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
|
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
|
||||||
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
||||||
|
import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel'
|
||||||
|
|
||||||
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function ItemsPanel({
|
|||||||
searchResults,
|
searchResults,
|
||||||
leadingTile,
|
leadingTile,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
functionTree,
|
||||||
}: {
|
}: {
|
||||||
items?: AssetInput[]
|
items?: AssetInput[]
|
||||||
/** Called when the search query changes (community edition uses this for server-side search) */
|
/** 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.
|
* or no search results). Replaces the default "No results" message.
|
||||||
*/
|
*/
|
||||||
emptyState?: React.ReactNode
|
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 (
|
||||||
|
<FunctionTreePanel
|
||||||
|
emptyState={emptyState}
|
||||||
|
functionTree={functionTree}
|
||||||
|
items={items}
|
||||||
|
leadingTile={leadingTile}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
searchResults={searchResults}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LegacyItemsPanel
|
||||||
|
emptyState={emptyState}
|
||||||
|
items={items}
|
||||||
|
leadingTile={leadingTile}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
searchResults={searchResults}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mode = useEditor((s) => s.mode)
|
||||||
const catalogCategory = useEditor((s) => s.catalogCategory)
|
const catalogCategory = useEditor((s) => s.catalogCategory)
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export { Slider } from './components/ui/primitives/slider'
|
|||||||
export { SceneLoader } from './components/ui/scene-loader'
|
export { SceneLoader } from './components/ui/scene-loader'
|
||||||
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
|
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
|
||||||
export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
|
export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
|
||||||
|
export type { FunctionTreeNode } from './components/ui/sidebar/panels/items-panel/function-tree-panel'
|
||||||
export {
|
export {
|
||||||
type ProjectVisibility,
|
type ProjectVisibility,
|
||||||
SettingsPanel,
|
SettingsPanel,
|
||||||
|
|||||||
Reference in New Issue
Block a user