/**
* The Home left pane: the building-block palette. A search box, the Blocks/Lists groups with
* their folder sub-tabs, and the chip cloud. Self-contained — it owns its own search query and
* active-group/sub-tab state and rebuilds its catalog (it's the only consumer). It reports up
* only the cross-pane actions: inserting a token into the composer and the hover tooltip.
* @module gui/components/home/BlockPalette
*/
import { Fragment, useEffect, useMemo, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { getBlocks, subscribeCatalog } from "../../lib/promptEngine.js";
import { foldersOf } from "../../lib/home/blockCategories.js";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const msgs = defineMessages({
buildingBlocks: {
id: "home.buildingBlocks",
defaultMessage: "Building blocks"
},
searchBlocks: {
id: "home.searchBlocks",
defaultMessage: "Search blocks…"
},
noBlocks: {
id: "home.noBlocks",
defaultMessage: "No building blocks match “{query}”."
},
all: {
id: "home.all",
defaultMessage: "all"
},
moreFilter: {
id: "home.moreFilter",
defaultMessage: "+{count} more — keep typing to filter"
},
closePalette: {
id: "home.closePalette",
defaultMessage: "Close building blocks",
description: "aria-label for the phone-only ✕ that closes the building-block drawer"
}
});
/**
* The building-block palette.
* @param {object} props
* @param {boolean} props.includeAdult Whether nsfw-flagged blocks are shown.
* @param {(token: string) => void} props.onInsert Insert a block token into the composer.
* @param {(item: object, e: object) => void} props.onShowTip Show the hover tooltip.
* @param {(e: object) => void} props.onMoveTip Move the hover tooltip.
* @param {() => void} props.onHideTip Hide the hover tooltip.
* @returns {JSX.Element}
*/
export default function BlockPalette({
includeAdult,
onInsert,
onShowTip,
onMoveTip,
onHideTip,
onClose
}) {
const intl = useIntl();
const [version, setVersion] = useState(0); // bump to refresh custom blocks
const [query, setQuery] = useState("");
const [activeCat, setActiveCat] = useState(""); // top group: Blocks | Lists
const [activeSub, setActiveSub] = useState("All"); // folder sub-tab within the active group
// Rebuild the block palette when custom blocks change or the NSFW switch flips (nsfw-flagged
// generators are hidden entirely when adult is off).
const blocks = useMemo(() => getBlocks({
includeAdult
}), [version, includeAdult]);
// Rebuild the palette when the catalog hot-applies (a Manage edit / refresh re-reads disk).
useEffect(() => subscribeCatalog(() => setVersion(v => v + 1)), []);
// Filter blocks by the single search box (matches token or label). Category pills
// (the Lists folder headers) are kept only when a following entry survives.
const q = query.trim().toLowerCase();
const matchItem = i => (i.token || "").toLowerCase().includes(q) || (i.label || "").toLowerCase().includes(q);
function filterItems(items) {
if (!q) return items;
const out = [];
for (let k = 0; k < items.length; k++) {
const i = items[k];
if (i.category) {
let any = false;
for (let j = k + 1; j < items.length && !items[j].category; j++) if (matchItem(items[j])) {
any = true;
break;
}
if (any) out.push(i);
} else if (matchItem(i)) {
out.push(i);
}
}
return out;
}
const effItems = b => b.items;
const filtered = blocks.map(b => ({
...b,
items: filterItems(effItems(b))
})).filter(b => b.items.some(i => !i.category));
// The active top group (Blocks / Lists), falling back to the first available.
const active = filtered.find(b => b.title === activeCat) || filtered[0] || null;
const searching = !!q;
// Folder sub-categories of the active group, and the currently-selected one (default All).
const subCats = active ? foldersOf(active) : [];
const effSub = activeSub === "All" || subCats.some(c => c.label === activeSub) ? activeSub : "All";
// The chips to render. Searching → the flat matched run. All → every chip across folders, plus
// the insertable group pills (the plain folder headers are sub-tabs now, so they're dropped). A
// folder sub-tab → that folder's chips, led by its whole-group insert pill when it has one.
let activeItems;
if (searching) {
activeItems = active ? active.items.filter(i => !i.category) : [];
} else if (effSub === "All") {
// All = every category shown with its pill header + buttons (special/any included as pills).
activeItems = active ? active.items : [];
} else {
const cat = subCats.find(c => c.label === effSub);
activeItems = cat ? [...(cat.token ? [{
category: true,
token: cat.token,
label: cat.label,
description: cat.description
}] : []), ...cat.items] : [];
}
return /*#__PURE__*/_jsxDEV("aside", {
className: "sidebar",
id: "block-palette",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "panel-head",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "panel-head-row",
children: [/*#__PURE__*/_jsxDEV("h3", {
className: "panel-title",
children: intl.formatMessage(msgs.buildingBlocks)
}, void 0, false), onClose && /*#__PURE__*/_jsxDEV("button", {
type: "button",
className: "palette-close",
onClick: onClose,
"aria-label": intl.formatMessage(msgs.closePalette),
title: intl.formatMessage(msgs.closePalette),
children: "✕"
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("input", {
className: "picker-filter",
placeholder: intl.formatMessage(msgs.searchBlocks),
value: query,
onChange: e => setQuery(e.target.value)
}, void 0, false)]
}, void 0, true), filtered.length === 0 ? /*#__PURE__*/_jsxDEV("p", {
className: "empty",
children: intl.formatMessage(msgs.noBlocks, {
query
})
}, void 0, false) : /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV("nav", {
className: "cat-tabs",
children: filtered.map(b => {
const isActiveGroup = !!active && active.title === b.title;
const groupFolders = foldersOf(b);
const selectGroup = () => {
setActiveCat(b.title);
setActiveSub("All");
};
return /*#__PURE__*/_jsxDEV(Fragment, {
children: [/*#__PURE__*/_jsxDEV("button", {
className: "cat-tab",
onClick: selectGroup,
children: [/*#__PURE__*/_jsxDEV("span", {
className: "cat-name",
children: b.title
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
className: "count-pill",
children: b.items.filter(i => !i.category).length
}, void 0, false)]
}, void 0, true), !searching && /*#__PURE__*/_jsxDEV("button", {
className: `cat-tab sub${isActiveGroup && effSub === "All" ? " on" : ""}`,
onClick: selectGroup,
children: [/*#__PURE__*/_jsxDEV("span", {
className: "cat-name",
children: intl.formatMessage(msgs.all)
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
className: "count-pill",
children: b.items.filter(i => !i.category).length
}, void 0, false)]
}, void 0, true), !searching && groupFolders.map(c => /*#__PURE__*/_jsxDEV("button", {
className: `cat-tab sub${isActiveGroup && effSub === c.label ? " on" : ""}`,
onClick: () => {
setActiveCat(b.title);
setActiveSub(c.label);
},
title: c.description || c.label,
children: [/*#__PURE__*/_jsxDEV("span", {
className: "cat-name",
children: c.label
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
className: "count-pill",
children: c.items.length
}, void 0, false)]
}, c.label, true))]
}, b.title, true);
})
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "chip-area",
children: [!searching && (() => {
const desc = effSub === "All" ? active?.hint : subCats.find(c => c.label === effSub)?.description;
return desc ? /*#__PURE__*/_jsxDEV("p", {
className: "cat-hint",
children: [/*#__PURE__*/_jsxDEV("span", {
className: "cat-hint-icon",
"aria-hidden": "true",
children: "ⓘ"
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
className: "cat-hint-text",
children: desc
}, void 0, false)]
}, void 0, true) : null;
})(), /*#__PURE__*/_jsxDEV("div", {
className: "picker-list",
children: [activeItems.slice(0, 400).map((i, idx) => i.category ? i.token ? /*#__PURE__*/_jsxDEV("button", {
className: "cat-pill cat-pill-group",
onMouseEnter: e => onShowTip(i, e),
onMouseMove: onMoveTip,
onMouseLeave: onHideTip,
onClick: () => onInsert(i.token),
children: i.label
}, `cat-${i.label}-${idx}`, false) : /*#__PURE__*/_jsxDEV("span", {
className: "cat-pill",
title: i.description || i.label,
children: i.label
}, `cat-${i.label}-${idx}`, false) : /*#__PURE__*/_jsxDEV("button", {
className: "chip",
onMouseEnter: e => onShowTip(i, e),
onMouseMove: onMoveTip,
onMouseLeave: onHideTip,
onClick: () => onInsert(i.token),
children: i.label
}, i.token, false)), activeItems.length > 400 && /*#__PURE__*/_jsxDEV("span", {
className: "picker-more",
children: intl.formatMessage(msgs.moreFilter, {
count: activeItems.length - 400
})
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true);
}