-

Node Catalog

PreviousNext

A searchable catalog for workflow nodes, tools, and builder actions.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/node-catalog.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/node-catalog.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/node-catalog.json

Component

components/ui/node-catalog.tsx
"use client"

import * as React from "react"
import { SearchIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"

export interface NodeCatalogItem {
  id: string
  label: React.ReactNode
  description?: React.ReactNode
  icon?: React.ReactNode
  category?: string
  tags?: string[]
}

export interface NodeCatalogProps
  extends Omit<React.ComponentProps<"div">, "onSelect" | "title"> {
  items: NodeCatalogItem[]
  title?: React.ReactNode
  searchPlaceholder?: string
  emptyText?: React.ReactNode
  onItemSelect?: (item: NodeCatalogItem) => void
}

export function NodeCatalog({
  items,
  title = "Node catalog",
  searchPlaceholder = "Search nodes...",
  emptyText = "No nodes found.",
  onItemSelect,
  className,
  ...props
}: NodeCatalogProps) {
  const [query, setQuery] = React.useState("")
  const filtered = React.useMemo(() => {
    const needle = query.trim().toLowerCase()
    if (!needle) return items

    return items.filter((item) =>
      [
        item.id,
        String(item.label),
        String(item.description ?? ""),
        item.category ?? "",
        ...(item.tags ?? []),
      ]
        .join(" ")
        .toLowerCase()
        .includes(needle)
    )
  }, [items, query])
  const groups = React.useMemo(() => {
    const grouped = new Map<string, NodeCatalogItem[]>()

    for (const item of filtered) {
      const category = item.category ?? "Nodes"
      grouped.set(category, [...(grouped.get(category) ?? []), item])
    }

    return Array.from(grouped.entries())
  }, [filtered])

  return (
    <div
      className={cn(
        "border-border bg-card text-card-foreground overflow-hidden rounded-lg border shadow-sm",
        className
      )}
      {...props}
    >
      <div className="border-border border-b p-3">
        <div className="mb-3 flex items-center justify-between gap-3">
          <p className="text-sm font-medium">{title}</p>
          <Badge variant="secondary" className="rounded-md">
            {filtered.length}
          </Badge>
        </div>
        <div className="relative">
          <SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
          <Input
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            placeholder={searchPlaceholder}
            className="h-9 pl-9"
          />
        </div>
      </div>
      <div className="max-h-96 overflow-auto p-2">
        {groups.length ? (
          groups.map(([category, categoryItems]) => (
            <div key={category} className="py-1">
              <p className="text-muted-foreground px-2 py-1.5 text-xs font-medium">
                {category}
              </p>
              <div className="space-y-1">
                {categoryItems.map((item) => (
                  <button
                    key={item.id}
                    type="button"
                    className="hover:bg-accent flex w-full items-start gap-3 rounded-md p-2 text-left transition-colors"
                    onClick={() => onItemSelect?.(item)}
                  >
                    {item.icon ? (
                      <span className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4">
                        {item.icon}
                      </span>
                    ) : null}
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-sm font-medium">
                        {item.label}
                      </span>
                      {item.description ? (
                        <span className="text-muted-foreground mt-0.5 line-clamp-2 block text-xs leading-5">
                          {item.description}
                        </span>
                      ) : null}
                    </span>
                  </button>
                ))}
              </div>
            </div>
          ))
        ) : (
          <div className="text-muted-foreground p-6 text-center text-sm">
            {emptyText}
          </div>
        )}
      </div>
    </div>
  )
}

Usage

import { NodeCatalog } from "@/components/ui/node-catalog"
 
export function Example() {
  return <NodeCatalog items={[{ id: "llm", label: "LLM" }]} />
}