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": {
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"bin": {
|
||||
"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]
|
||||
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]),
|
||||
|
||||
@@ -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 { 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 (
|
||||
<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 catalogCategory = useEditor((s) => s.catalogCategory)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user