feat(recipe-studio): add block sheet search + clearer icons in sheet

This commit is contained in:
Shine1i 2026-02-26 12:55:09 +01:00
commit 1e3d50f876

View file

@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
@ -12,6 +13,7 @@ import {
CodeIcon,
Copy02Icon,
type Database02Icon,
DragDropVerticalIcon,
PlusSignIcon,
Tick02Icon,
Upload01Icon,
@ -115,6 +117,15 @@ const ROOT_GROUPS: RootGroup[] = [
icon: CodeIcon,
},
];
const SEARCHABLE_KINDS: SheetKind[] = [
"sampler",
"seed",
"llm",
"expression",
"note",
];
const PROCESSOR_TITLE = "Schema Transform";
const PROCESSOR_DESCRIPTION = "Transform final dataset schema.";
function BlockSheetButton({
icon,
@ -124,7 +135,7 @@ function BlockSheetButton({
isActive = false,
draggable = false,
onDragStart,
showChevron = true,
trailing = "chevron",
}: {
icon: typeof Database02Icon;
title: string;
@ -133,7 +144,7 @@ function BlockSheetButton({
isActive?: boolean;
draggable?: boolean;
onDragStart?: (event: ReactDragEvent<HTMLButtonElement>) => void;
showChevron?: boolean;
trailing?: "chevron" | "drag" | "none";
}): ReactElement {
return (
<button
@ -154,11 +165,16 @@ function BlockSheetButton({
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
{showChevron ? (
{trailing === "chevron" ? (
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
) : trailing === "drag" ? (
<HugeiconsIcon
icon={DragDropVerticalIcon}
className="size-5 text-primary/80"
/>
) : null}
</button>
);
@ -184,11 +200,17 @@ export function BlockSheet({
}: BlockSheetProps): ReactElement {
const sheetTitle = getSheetTitle(sheetView);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const [search, setSearch] = useState("");
const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []);
const noteBlocks = useMemo(() => getBlocksForKind("note"), []);
const seedBlocks = useMemo(() => getBlocksForKind("seed"), []);
const isControlled = typeof open === "boolean";
const sheetOpen = isControlled ? (open as boolean) : uncontrolledOpen;
const normalizedSearch = search.trim().toLowerCase();
const hasSearch = normalizedSearch.length > 0;
const isProcessorView = sheetView === "processor";
const isRootView = sheetView === "root";
const isScopedBlockView = !isRootView && !isProcessorView;
const setSheetOpen = (nextOpen: boolean) => {
if (!isControlled) {
@ -196,6 +218,56 @@ export function BlockSheet({
}
onOpenChange?.(nextOpen);
};
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(normalizedSearch) ||
description.toLowerCase().includes(normalizedSearch);
const searchableBlocks = useMemo(
() => SEARCHABLE_KINDS.flatMap((kind) => getBlocksForKind(kind)),
[],
);
const rootSearchBlocks = useMemo(() => {
if (!hasSearch) {
return [];
}
return searchableBlocks.filter((item) =>
matchesSearch(item.title, item.description),
);
}, [hasSearch, searchableBlocks, normalizedSearch]);
const scopedBlocks = useMemo(() => {
if (!isScopedBlockView) {
return [];
}
const blocks = getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler");
if (!hasSearch) {
return blocks;
}
return blocks.filter((item) => matchesSearch(item.title, item.description));
}, [hasSearch, isScopedBlockView, normalizedSearch, sheetView]);
const rootGroups = useMemo(() => {
if (!hasSearch) {
return ROOT_GROUPS;
}
return ROOT_GROUPS.filter((group) => {
if (matchesSearch(group.title, group.description)) {
return true;
}
if (group.kind === "processor") {
return matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION);
}
return getBlocksForKind(group.kind).some((item) =>
matchesSearch(item.title, item.description),
);
});
}, [hasSearch, normalizedSearch]);
const showNoMatches =
(isRootView && hasSearch && rootSearchBlocks.length === 0) ||
(isScopedBlockView && scopedBlocks.length === 0) ||
(isProcessorView &&
hasSearch &&
!matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION));
const buildDragStart =
(kind: SheetKind, type: BlockType) =>
@ -206,6 +278,36 @@ export function BlockSheet({
event.dataTransfer.setData("text/plain", serialized);
event.dataTransfer.effectAllowed = "copy";
};
const getTrailing = (kind: SheetKind): "drag" | "none" =>
kind === "sampler" || kind === "seed" || kind === "llm" ? "drag" : "none";
const onBlockClick = (kind: SheetKind, type: BlockType) => {
setSheetOpen(false);
if (kind === "sampler") {
onAddSampler(type as SamplerType);
return;
}
if (kind === "seed") {
onAddSeed(type as SeedBlockType);
return;
}
if (kind === "llm") {
if (type === "model_provider") {
onAddModelProvider();
return;
}
if (type === "model_config") {
onAddModelConfig();
return;
}
onAddLlm(type as LlmType);
return;
}
if (kind === "expression") {
onAddExpression();
return;
}
onAddMarkdownNote();
};
return (
<div className="flex flex-col items-end gap-2">
@ -215,6 +317,7 @@ export function BlockSheet({
setSheetOpen(nextOpen);
if (nextOpen) {
onViewChange("root");
setSearch("");
}
}}
>
@ -238,7 +341,7 @@ export function BlockSheet({
className="absolute gap-0 p-0 shadow-none"
overlayClassName="bg-transparent pointer-events-none backdrop-blur-none supports-backdrop-filter:backdrop-blur-none"
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<SheetHeader className="px-6 py-5">
<div className="flex items-center gap-2">
{sheetView !== "root" && (
<Button
@ -252,17 +355,44 @@ export function BlockSheet({
)}
<SheetTitle>{sheetTitle}</SheetTitle>
</div>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search blocks..."
className="corner-squircle mt-3 h-9"
/>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">
{sheetView === "root" &&
ROOT_GROUPS.map((item, index) => (
{isRootView &&
hasSearch &&
rootSearchBlocks.map((item, index) => (
<BlockSheetButton
key={`${item.kind}:${item.type}`}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing(item.kind)}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isRootView &&
!hasSearch &&
rootGroups.map((item, index) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
trailing={
item.kind === "expression" || item.kind === "note"
? "none"
: "chevron"
}
onClick={() => {
if (item.kind === "processor") {
setSheetOpen(false);
@ -288,18 +418,20 @@ export function BlockSheet({
}}
/>
))}
{sheetView === "processor" && (
<BlockSheetButton
icon={CodeIcon}
title="Schema Transform"
description="Transform final dataset schema."
isActive={true}
onClick={onOpenProcessors}
/>
{isProcessorView && (
(!hasSearch ||
matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION)) && (
<BlockSheetButton
icon={CodeIcon}
title={PROCESSOR_TITLE}
description={PROCESSOR_DESCRIPTION}
isActive={true}
onClick={onOpenProcessors}
/>
)
)}
{sheetView !== "root" &&
sheetView !== "processor" &&
getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler").map(
{isScopedBlockView &&
scopedBlocks.map(
(item, index) => (
<BlockSheetButton
key={item.type}
@ -309,30 +441,16 @@ export function BlockSheet({
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
showChevron={!(sheetView === "expression" || sheetView === "note")}
onClick={() => {
setSheetOpen(false);
if (item.kind === "sampler") {
onAddSampler(item.type as SamplerType);
} else if (item.kind === "seed") {
onAddSeed(item.type as SeedBlockType);
} else if (item.kind === "llm") {
if (item.type === "model_provider") {
onAddModelProvider();
} else if (item.type === "model_config") {
onAddModelConfig();
} else {
onAddLlm(item.type as LlmType);
}
} else if (item.kind === "expression") {
onAddExpression();
} else {
onAddMarkdownNote();
}
}}
trailing={getTrailing(item.kind)}
onClick={() => onBlockClick(item.kind, item.type)}
/>
),
)}
{showNoMatches && (
<p className="px-3 py-2 text-xs text-muted-foreground">
No blocks match.
</p>
)}
</div>
</div>
</SheetContent>