Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/core/src/schema/nodes/item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 &ldquo;{search}&rdquo;
</div>
))
) : (
<ItemCatalog
category={'furnish' as never}
emptyState={emptyState}
key={activeNode?.slug ?? 'all'}
leadingTile={leadingTile}
overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
/>
)}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand All @@ -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) */
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading