feat(frontend): Update block UI (#8260)

* feat(platform): Update block UI

* add border on card

* added delay and badge data-id

* Fix border & width for block control list

* More cleanup on border & shadow

* Nav border consistency

* Simplify category badges

* restored backward compatablility

* fix alignement of sub handles

* Fix dynamic pin experience

* Added a timeout to prevent losing focus whilst typing

* Added flex-col back in removed timeout

* Clear nodes before tutorial

* Fix highlight on tutorial

* Sort blocks

* lint

* Fix tutorial and lint error

* w-fit

* Fix tutorial modals silly jumps!

* updates to tutorial

* prettier

* add data-id to save control bar

* prettier again

---------

Co-authored-by: Swifty <craigswift13@gmail.com>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Bently <tomnoon9@gmail.com>
pull/8327/head
Zamil Majdy 2024-10-12 12:33:29 +03:00 committed by GitHub
parent c1f97415fb
commit 8502928a21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1850 additions and 2016 deletions

View File

@ -27,6 +27,7 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",

View File

@ -105,7 +105,7 @@ const AgentCard: React.FC<{ agent: Agent; featured?: boolean }> = ({
return (
<div
className={`flex cursor-pointer flex-col justify-between rounded-lg border p-6 transition-colors duration-200 hover:bg-gray-50 ${featured ? "border-indigo-500 shadow-md" : "border-gray-200"}`}
className={`flex cursor-pointer flex-col justify-between rounded-lg border p-6 transition-colors duration-200 hover:bg-gray-50 ${featured ? "border-indigo-500 shadow-md" : "border-gray-300"}`}
onClick={handleClick}
>
<div>

View File

@ -48,9 +48,9 @@ export function CustomEdge({
}>({ beads: [], created: 0, destroyed: 0 });
const { svgPath, length, getPointForT, getTForDistance } = useBezierPath(
sourceX - 5,
sourceY,
targetX + 3,
targetY,
sourceY - 5,
targetX - 9,
targetY - 5,
);
const { deleteElements } = useReactFlow<Node, CustomEdge>();
const { visualizeBeads } = useContext(FlowContext) ?? {

View File

@ -21,19 +21,20 @@ import {
import { beautifyString, cn, setNestedProperty } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Copy, Trash2 } from "lucide-react";
import { history } from "./history";
import NodeHandle from "./NodeHandle";
import {
NodeGenericInputField,
NodeTextBoxInput,
} from "./node-input-components";
import SchemaTooltip from "./SchemaTooltip";
import { getPrimaryCategoryColor } from "@/lib/utils";
import { FlowContext } from "./Flow";
import { Badge } from "./ui/badge";
import DataTable from "./DataTable";
import NodeOutputs from "./NodeOutputs";
import { IconCoin } from "./ui/icons";
import * as Separator from "@radix-ui/react-separator";
import * as ContextMenu from "@radix-ui/react-context-menu";
import { DotsVerticalIcon, TrashIcon, CopyIcon } from "@radix-ui/react-icons";
type ParsedKey = { key: string; index?: number };
@ -72,14 +73,19 @@ export type CustomNodeData = {
export type CustomNode = Node<CustomNodeData, "custom">;
export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
export function CustomNode({
data,
id,
width,
height,
selected,
}: NodeProps<CustomNode>) {
const [isOutputOpen, setIsOutputOpen] = useState(data.isOutputOpen || false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [inputModalValue, setInputModalValue] = useState<string>("");
const [isOutputModalOpen, setIsOutputModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { updateNodeData, deleteElements, addNodes, getNode } = useReactFlow<
CustomNode,
Edge
@ -165,15 +171,14 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
const isAdvanced = propSchema.advanced;
return (
(isRequired || isAdvancedOpen || !isAdvanced) && (
<div key={propKey}>
<span className="text-m green -mb-1 text-gray-900">
<div key={propKey} data-id={`input-handle-${propKey}`}>
<span className="text-m green mb-0 text-gray-900">
{propSchema.title || beautifyString(propKey)}
</span>
<div key={propKey} onMouseOver={() => {}}>
<div key={propKey}>
{!isConnected && (
<NodeGenericInputField
nodeId={id}
className="mb-2 mt-1"
propKey={propKey}
propSchema={propSchema}
currentValue={getValue(propKey)}
@ -216,9 +221,9 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
const isAdvanced = propSchema.advanced;
return (
(isRequired || isAdvancedOpen || !isAdvanced) && (
<div key={propKey} onMouseOver={() => {}}>
<div key={propKey} data-id={`output-handle-${propKey}`}>
{propKey !== "value" ? (
<span className="text-m green -mb-1 text-gray-900">
<span className="text-m green mb-0 text-gray-900">
{propSchema.title || beautifyString(propKey)}
</span>
) : (
@ -233,7 +238,6 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
{!isConnected && (
<NodeGenericInputField
nodeId={id}
className="mb-2 mt-1"
propKey={propKey}
propSchema={propSchema}
currentValue={getValue(propKey)}
@ -256,9 +260,9 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
const isAdvanced = propSchema.advanced;
return (
(isRequired || isAdvancedOpen || isConnected || !isAdvanced) && (
<div key={propKey} onMouseOver={() => {}}>
<div key={propKey} data-id={`input-handle-${propKey}`}>
{"credentials_provider" in propSchema ? (
<span className="text-m green -mb-1 text-gray-900">
<span className="text-m green mb-0 text-gray-900">
Credentials
</span>
) : (
@ -273,7 +277,6 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
{!isConnected && (
<NodeGenericInputField
nodeId={id}
className="mb-2 mt-1"
propKey={propKey}
propSchema={propSchema}
currentValue={getValue(propKey)}
@ -395,7 +398,7 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
};
const handleInputClick = (key: string) => {
console.log(`Opening modal for key: ${key}`);
console.debug(`Opening modal for key: ${key}`);
setActiveKey(key);
const value = getValue(key);
setInputModalValue(
@ -421,16 +424,8 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
setIsOutputModalOpen(true);
};
const handleHovered = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const deleteNode = useCallback(() => {
console.log("Deleting node:", id);
console.debug("Deleting node:", id);
// Remove the node
deleteElements({ nodes: [{ id }] });
@ -467,7 +462,7 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
history.push({
type: "ADD_NODE",
payload: { node: newNode },
payload: { node: { ...newNode, ...newNode.data } as CustomNodeData },
undo: () => deleteElements({ nodes: [{ id: newId }] }),
redo: () => addNodes(newNode),
});
@ -510,20 +505,53 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
"custom-node",
"dark-theme",
"rounded-xl",
"border",
"bg-white/[.9]",
"shadow-md",
"border border-gray-300",
data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]",
data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white",
selected ? "shadow-2xl" : "",
]
.filter(Boolean)
.join(" ");
const errorClass =
hasConfigErrors || hasOutputError ? "border-red-500 border-2" : "";
hasConfigErrors || hasOutputError ? "border-red-200 border-2" : "";
const statusClass =
hasConfigErrors || hasOutputError
? "failed"
: (data.status?.toLowerCase() ?? "");
const statusClass = (() => {
if (hasConfigErrors || hasOutputError) return "border-red-200 border-4";
switch (data.status?.toLowerCase()) {
case "completed":
return "border-green-200 border-4";
case "running":
return "border-yellow-200 border-4";
case "failed":
return "border-red-200 border-4";
case "incomplete":
return "border-purple-200 border-4";
case "queued":
return "border-cyan-200 border-4";
default:
return "";
}
})();
const statusBackgroundClass = (() => {
if (hasConfigErrors || hasOutputError) return "bg-red-200";
switch (data.status?.toLowerCase()) {
case "completed":
return "bg-green-200";
case "running":
return "bg-yellow-200";
case "failed":
return "bg-red-200";
case "incomplete":
return "bg-purple-200";
case "queued":
return "bg-cyan-200";
default:
return "";
}
})();
const hasAdvancedFields =
data.inputSchema &&
@ -544,115 +572,212 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
),
);
return (
<div
className={`${data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]"} ${blockClasses} ${errorClass} ${statusClass} ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white"}`}
onMouseEnter={handleHovered}
onMouseLeave={handleMouseLeave}
data-id={`custom-node-${id}`}
>
<div
className={`mb-2 p-3 ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : getPrimaryCategoryColor(data.categories)} rounded-t-xl`}
const LineSeparator = () => (
<div className="bg-white pt-6">
<Separator.Root className="h-[1px] w-full bg-gray-300"></Separator.Root>
</div>
);
const ContextMenuContent = () => (
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md">
<ContextMenu.Item
onSelect={copyNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div className="font-roboto p-3 text-lg font-semibold">
{beautifyString(
data.blockType?.replace(/Block$/, "") || data.title,
)}
<CopyIcon className="mr-2 h-5 w-5" />
<span>Copy</span>
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-gray-300" />
<ContextMenu.Item
onSelect={deleteNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500" />
<span>Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
);
const onContextButtonTrigger = (e: React.MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const event = new MouseEvent("contextmenu", {
bubbles: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
});
e.currentTarget.dispatchEvent(event);
};
const stripeColor = getPrimaryCategoryColor(data.categories);
const nodeContent = () => (
<div
className={`${blockClasses} ${errorClass} ${statusClass}`}
data-id={`custom-node-${id}`}
z-index={1}
>
{/* Header */}
<div
className={`flex h-24 border-b border-gray-300 ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white"} items-center rounded-t-xl`}
>
{/* Color Stripe */}
<div className={`-ml-px h-full w-3 rounded-tl-xl ${stripeColor}`}></div>
<div className="flex w-full flex-col">
<div className="flex flex-row items-center justify-between">
<div className="font-roboto px-3 text-lg font-semibold">
{beautifyString(
data.blockType?.replace(/Block$/, "") || data.title,
)}
</div>
</div>
<SchemaTooltip description={data.description} />
</div>
<div className="flex gap-[5px]">
{isHovered && (
<>
<Button
variant="outline"
size="icon"
onClick={copyNode}
title="Copy node"
>
<Copy size={18} />
</Button>
<Button
variant="outline"
size="icon"
onClick={deleteNode}
title="Delete node"
>
<Trash2 size={18} />
</Button>
</>
{blockCost && (
<div className="px-3 text-base font-light">
<span className="ml-auto flex items-center">
<IconCoin />{" "}
<span className="m-1 font-medium">{blockCost.cost_amount}</span>{" "}
credits/{blockCost.cost_type}
</span>
</div>
)}
</div>
{data.categories.map((category) => (
<Badge
key={category.category}
variant="outline"
className={`mr-5 ${getPrimaryCategoryColor([category])} rounded-xl border border-gray-300 opacity-50`}
>
{beautifyString(category.category.toLowerCase())}
</Badge>
))}
<button
aria-label="Options"
className="mr-2 cursor-pointer rounded-full border-none bg-transparent p-1 hover:bg-gray-100"
onClick={onContextButtonTrigger}
>
<DotsVerticalIcon className="h-5 w-5" />
</button>
<ContextMenuContent />
</div>
{blockCost && (
<div className="p-3 font-semibold">
<span className="ml-auto flex items-center">
<IconCoin /> {blockCost.cost_amount} credits/{blockCost.cost_type}
</span>
</div>
)}
{data.uiType !== BlockUIType.NOTE ? (
<div className="flex items-start justify-between p-3">
{/* Body */}
<div className="ml-5 mt-6 rounded-b-xl">
{/* Input Handles */}
{data.uiType !== BlockUIType.NOTE ? (
<div
className="flex w-fit items-start justify-between"
data-id="input-handles"
>
<div>
{data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)}
</div>
</div>
) : (
<div>
{data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)}
</div>
<div className="flex-none">
{data.outputSchema &&
generateOutputHandles(data.outputSchema, data.uiType)}
</div>
</div>
) : (
<div>
{data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)}
</div>
)}
{isOutputOpen && data.uiType !== BlockUIType.NOTE && (
<div
data-id="latest-output"
className="nodrag m-3 break-words rounded-md border-[1.5px] p-2"
>
{(data.executionResults?.length ?? 0) > 0 ? (
<>
<DataTable
title="Latest Output"
truncateLongData
data={data.executionResults!.at(-1)?.data || {}}
)}
{/* Advanced Settings */}
{data.uiType !== BlockUIType.NOTE && hasAdvancedFields && (
<>
<LineSeparator />
<div className="flex items-center justify-between pt-6">
Advanced
<Switch
onCheckedChange={toggleAdvancedSettings}
checked={isAdvancedOpen}
className="mr-5"
/>
<div className="flex justify-end">
<Button variant="ghost" onClick={handleOutputClick}>
View More
</Button>
</div>
</>
)}
{/* Output Handles */}
{data.uiType !== BlockUIType.NOTE && (
<>
<LineSeparator />
<div className="flex items-start justify-end rounded-b-xl pb-2 pr-2 pt-6">
<div className="flex-none">
{data.outputSchema &&
generateOutputHandles(data.outputSchema, data.uiType)}
</div>
</>
) : (
<span>No outputs yet</span>
)}
</div>
)}
{data.uiType !== BlockUIType.NOTE && (
<div className="mt-2.5 flex items-center pb-4 pl-4">
<Switch checked={isOutputOpen} onCheckedChange={toggleOutput} />
<span className="m-1 mr-4">Output</span>
{hasAdvancedFields && (
<>
<Switch onCheckedChange={toggleAdvancedSettings} />
<span className="m-1">Advanced</span>
</>
)}
{data.status && (
<Badge
variant="outline"
data-id={`badge-${id}-${data.status}`}
className={cn(data.status.toLowerCase(), "ml-auto mr-5")}
</div>
</>
)}
</div>
{/* End Body */}
{/* Footer */}
<div className="flex rounded-b-xl">
{/* Display Outputs */}
{isOutputOpen && data.uiType !== BlockUIType.NOTE && (
<div
data-id="latest-output"
className={cn(
"nodrag w-full overflow-hidden break-words",
statusBackgroundClass,
)}
>
{(data.executionResults?.length ?? 0) > 0 ? (
<div className="mt-0 rounded-b-xl bg-gray-50">
<LineSeparator />
<NodeOutputs
title="Latest Output"
truncateLongData
data={data.executionResults!.at(-1)?.data || {}}
/>
<div className="flex justify-end">
<Button
variant="ghost"
onClick={handleOutputClick}
className="border border-gray-300"
>
View More
</Button>
</div>
</div>
) : (
<div className="mt-0 min-h-4 rounded-b-xl bg-white"></div>
)}
<div
className={cn(
"flex min-h-12 items-center justify-end",
statusBackgroundClass,
)}
>
{data.status}
</Badge>
)}
</div>
)}
<Badge
variant="default"
data-id={`badge-${id}-${data.status}`}
className={cn(
"mr-4 flex min-w-[114px] items-center justify-center rounded-3xl text-center text-xs font-semibold",
hasConfigErrors || hasOutputError
? "border-red-600 bg-red-600 text-white"
: {
"border-green-600 bg-green-600 text-white":
data.status === "COMPLETED",
"border-yellow-600 bg-yellow-600 text-white":
data.status === "RUNNING",
"border-red-600 bg-red-600 text-white":
data.status === "FAILED",
"border-blue-600 bg-blue-600 text-white":
data.status === "QUEUED",
"border-gray-600 bg-gray-600 font-black":
data.status === "INCOMPLETE",
},
)}
>
{hasConfigErrors || hasOutputError
? "Error"
: data.status
? beautifyString(data.status)
: "Not Run"}
</Badge>
</div>
</div>
)}
</div>
<InputModalComponent
title={activeKey ? `Enter ${beautifyString(activeKey)}` : undefined}
isOpen={isModalOpen}
@ -668,4 +793,10 @@ export function CustomNode({ data, id, width, height }: NodeProps<CustomNode>) {
/>
</div>
);
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
</ContextMenu.Root>
);
}

View File

@ -44,6 +44,7 @@ import RunnerUIWrapper, {
} from "@/components/RunnerUIWrapper";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import { useToast } from "@/components/ui/use-toast";
import { forceLoad } from "@sentry/nextjs";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@ -113,10 +114,20 @@ const FlowEditor: React.FC<{
localStorage.removeItem(TUTORIAL_STORAGE_KEY);
router.push(pathname);
} else if (!localStorage.getItem(TUTORIAL_STORAGE_KEY)) {
startTutorial(setPinBlocksPopover, setPinSavePopover);
const emptyNodes = (forceRemove: boolean = false) =>
forceRemove ? (setNodes([]), setEdges([]), true) : nodes.length === 0;
startTutorial(emptyNodes, setPinBlocksPopover, setPinSavePopover);
localStorage.setItem(TUTORIAL_STORAGE_KEY, "yes");
}
}, [availableNodes, router, pathname, params]);
}, [
availableNodes,
router,
pathname,
params,
setEdges,
setNodes,
nodes.length,
]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -423,7 +434,7 @@ const FlowEditor: React.FC<{
history.push({
type: "ADD_NODE",
payload: { node: newNode.data },
payload: { node: { ...newNode, ...newNode.data } },
undo: () => deleteElements({ nodes: [{ id: newNode.id }] }),
redo: () => addNodes(newNode),
});

View File

@ -18,7 +18,7 @@ export async function NavBar() {
const { user } = await getServerUser();
return (
<header className="sticky top-0 z-50 mx-4 flex h-16 items-center gap-4 border-b bg-background p-3 md:rounded-b-2xl md:px-6 md:shadow">
<header className="sticky top-0 z-50 mx-4 flex h-16 items-center gap-4 border border-gray-300 bg-background p-3 md:rounded-b-2xl md:px-6 md:shadow">
<div className="flex flex-1 items-center gap-4">
<Sheet>
<SheetTrigger asChild>

View File

@ -31,20 +31,27 @@ const NodeHandle: FC<HandleProps> = ({
const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${side === "left" ? "text-left" : "text-right"}`;
const label = (
<div className="flex flex-grow flex-col">
<span className="text-m green -mb-1 text-gray-900">
{schema.title || beautifyString(keyName)}
<div className="flex flex-grow flex-row">
<span className="text-m green flex items-end pr-2 text-gray-900">
{schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
</span>
<span className={typeClass}>{typeName[schema.type] || "any"}</span>
<span className={`${typeClass} flex items-end`}>
({typeName[schema.type as keyof typeof typeName] || "any"})
</span>
</div>
);
const dot = (
<div
className={`m-1 h-4 w-4 border-2 bg-white ${isConnected ? getTypeBgColor(schema.type || "any") : "border-gray-300"} rounded-full transition-colors duration-100 group-hover:bg-gray-300`}
/>
);
const Dot = ({ className = "" }) => {
const color = isConnected
? getTypeBgColor(schema.type || "any")
: "border-gray-300";
return (
<div
className={`${className} ${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300`}
/>
);
};
if (side === "left") {
return (
@ -53,10 +60,10 @@ const NodeHandle: FC<HandleProps> = ({
type="target"
position={Position.Left}
id={keyName}
className="background-color: white; border: 2px solid black; width: 15px; height: 15px; border-radius: 50%; bottom: -7px; left: 20%; group -ml-[26px]"
className="-ml-[26px]"
>
<div className="pointer-events-none flex items-center">
{dot}
<Dot className={`-ml-2 mr-2`} />
{label}
</div>
</Handle>
@ -74,7 +81,7 @@ const NodeHandle: FC<HandleProps> = ({
>
<div className="pointer-events-none flex items-center">
{label}
{dot}
<Dot />
</div>
</Handle>
</div>

View File

@ -0,0 +1,45 @@
import React from "react";
import { ContentRenderer } from "./ui/render";
import { beautifyString } from "@/lib/utils";
import * as Separator from "@radix-ui/react-separator";
type NodeOutputsProps = {
title?: string;
truncateLongData?: boolean;
data: { [key: string]: Array<any> };
};
export default function NodeOutputs({
title,
truncateLongData,
data,
}: NodeOutputsProps) {
return (
<div className="m-4 space-y-4">
{title && <strong className="mt-2flex">{title}</strong>}
{Object.entries(data).map(([pin, dataArray]) => (
<div key={pin} className="">
<div className="flex items-center">
<strong className="mr-2">Pin:</strong>
<span>{beautifyString(pin)}</span>
</div>
<Separator.Root className="my-4 h-[1px] bg-gray-300" />
<div className="mt-2">
<strong className="mr-2">Data:</strong>
<div className="mt-1">
{dataArray.map((item, index) => (
<React.Fragment key={index}>
<ContentRenderer
value={item}
truncateLongData={truncateLongData}
/>
{index < dataArray.length - 1 && ", "}
</React.Fragment>
))}
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -26,7 +26,7 @@ const OutputModalComponent: FC<OutputModalProps> = ({
<div className="nodrag nowheel fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
<div className="w-[500px] max-w-[90%] rounded-lg border-[1.5px] bg-white p-5">
<strong>Output Data History</strong>
<div className="my-2 max-h-[384px] flex-grow overflow-y-auto rounded-md border-[1.5px] p-2">
<div className="my-2 max-h-[384px] flex-grow overflow-y-auto rounded-md p-2">
{executionResults.map((data, i) => (
<>
<DataTable key={i} title={data.execId} data={data.data} />

View File

@ -59,7 +59,7 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
onClick={runButtonOnClick}
size="primary"
style={{
background: isRunning ? "#FFB3BA" : "#7544DF",
background: isRunning ? "#DF4444" : "#7544DF",
opacity: isDisabled ? 0.5 : 1,
}}
data-id="primary-action-run-agent"

View File

@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { IconMegaphone } from "@/components/ui/icons";
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons";
import { useRouter } from "next/navigation";
const TallyPopupSimple = () => {
@ -48,17 +48,22 @@ const TallyPopupSimple = () => {
};
return (
<div className="fixed bottom-6 right-6 z-50 hidden items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<Button variant="default" onClick={resetTutorial} className="mb-0">
<div className="fixed bottom-1 right-6 z-50 hidden items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
>
Tutorial
</Button>
<Button
className="h-14 w-14 rounded-2xl bg-[rgba(65,65,64,1)]"
variant="default"
data-tally-open="3yx2L0"
data-tally-emoji-text="👋"
data-tally-emoji-animation="wave"
>
<IconMegaphone size="lg" />
<QuestionMarkCircledIcon className="h-6 w-6" />
<span className="sr-only">Reach Out</span>
</Button>
</div>

View File

@ -4,20 +4,6 @@
transition: border-color 0.3s ease-in-out;
}
.custom-node .mb-2 {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px;
/* Increased to accommodate larger buttons */
margin-bottom: 10px;
}
.custom-node .mb-2 .text-lg {
flex-grow: 1;
margin-right: 10px;
}
/* Existing styles */
.handle-container {
display: flex;

View File

@ -2,7 +2,6 @@ import React, { useState } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { ToyBrick } from "lucide-react";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { beautifyString } from "@/lib/utils";
@ -12,11 +11,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Block } from "@/lib/autogpt-server-api";
import { PlusIcon } from "@radix-ui/react-icons";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/ui/icons";
import SchemaTooltip from "@/components/SchemaTooltip";
import { getPrimaryCategoryColor } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
@ -43,26 +40,28 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
addBlock,
pinBlocksPopover,
}) => {
const blockList = blocks.sort((a, b) => a.name.localeCompare(b.name));
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [filteredBlocks, setFilteredBlocks] = useState<Block[]>(blocks);
const [filteredBlocks, setFilteredBlocks] = useState<Block[]>(blockList);
const resetFilters = React.useCallback(() => {
setSearchQuery("");
setSelectedCategory(null);
setFilteredBlocks(blocks);
}, [blocks]);
setFilteredBlocks(blockList);
}, [blockList]);
// Extract unique categories from blocks
const categories = Array.from(
new Set(
blocks.flatMap((block) => block.categories.map((cat) => cat.category)),
),
new Set([
null,
...blocks.flatMap((block) => block.categories.map((cat) => cat.category)),
]),
);
React.useEffect(() => {
setFilteredBlocks(
blocks.filter(
blockList.filter(
(block: Block) =>
(block.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
beautifyString(block.name)
@ -72,7 +71,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
block.categories.some((cat) => cat.category === selectedCategory)),
),
);
}, [blocks, searchQuery, selectedCategory]);
}, [blockList, searchQuery, selectedCategory]);
return (
<Popover
@ -97,80 +96,89 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
side="right"
sideOffset={22}
align="start"
className="w-[30rem] p-0"
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="blocks-control-popover-content"
>
<Card className="border-none shadow-md">
<CardHeader className="flex flex-col gap-x-8 gap-y-2 p-3 px-2">
<Card className="p-3 pb-0">
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
<div className="items-center justify-between">
<Label
htmlFor="search-blocks"
className="whitespace-nowrap border-b-2 border-violet-500 text-base font-semibold text-black 2xl:text-xl"
className="whitespace-nowrap text-base font-bold text-black 2xl:text-xl"
data-id="blocks-control-label"
>
Blocks
</Label>
</div>
<Input
id="search-blocks"
type="text"
placeholder="Search blocks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-id="blocks-control-search-input"
/>
<div className="relative flex items-center">
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500" />
<Input
id="search-blocks"
type="text"
placeholder="Search blocks"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rounded-lg px-8 py-5"
data-id="blocks-control-search-input"
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{categories.map((category) => (
<Badge
key={category}
variant={
selectedCategory === category ? "default" : "outline"
}
className={`cursor-pointer ${getPrimaryCategoryColor([{ category, description: "" }])}`}
onClick={() =>
setSelectedCategory(
selectedCategory === category ? null : category,
)
}
>
{beautifyString(category)}
</Badge>
))}
{categories.map((category) => {
const color = getPrimaryCategoryColor([
{ category: category || "All", description: "" },
]);
const colorClass =
selectedCategory === category ? `${color}` : "";
return (
<div
key={category}
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium ${colorClass}`}
onClick={() =>
setSelectedCategory(
selectedCategory === category ? null : category,
)
}
>
{beautifyString((category || "All").toLowerCase())}
</div>
);
})}
</div>
</CardHeader>
<CardContent className="border-t px-1 py-0">
<CardContent className="overflow-scroll border-t p-0">
<ScrollArea
className="h-[60vh]"
className="h-[60vh] w-fit w-full"
data-id="blocks-control-scroll-area"
>
{filteredBlocks.map((block) => (
<Card
key={block.id}
className="m-2 my-4 flex h-20 border"
className="m-2 my-4 flex h-20 cursor-pointer shadow-none hover:shadow-lg"
data-id={`block-card-${block.id}`}
onClick={() => addBlock(block.id, block.name)}
>
{/* This div needs to be 10px wide and the same height as the card and be the primary color showing up on top of the card with matching rounded corners */}
<div
className={`z-20 flex min-w-4 flex-shrink-0 rounded-l-xl border ${getPrimaryCategoryColor(block.categories)}`}
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
></div>
<div className="mx-3 flex flex-1 items-center justify-between">
<div className="mr-2 min-w-0 flex-1">
<div className="mr-2 min-w-0">
<span
className="block truncate text-base font-semibold"
className="block truncate pb-1 text-sm font-semibold"
data-id={`block-name-${block.id}`}
>
{beautifyString(block.name)}
{beautifyString(block.name).replace(/ Block$/, "")}
</span>
<span className="block break-words text-sm font-normal text-gray-500">
<span className="block break-words text-xs font-normal text-gray-500">
{block.description}
</span>
</div>
<div
className="flex flex-shrink-0 items-center gap-1"
data-id={`block-tooltip-${block.id}`}
></div>
>
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1" />
</div>
</div>
</Card>
))}

View File

@ -47,7 +47,7 @@ export const ControlPanel = ({
return (
<Card className={cn("m-4 mt-24 w-14", className)}>
<CardContent className="p-0">
<div className="flex flex-col items-center gap-3 rounded-xl border py-3">
<div className="flex flex-col items-center gap-3 rounded-xl py-3">
{topChildren}
<Separator />
{controls.map((control, index) => (

View File

@ -76,7 +76,12 @@ export const SaveControl = ({
</TooltipTrigger>
<TooltipContent side="right">Save</TooltipContent>
</Tooltip>
<PopoverContent side="right" sideOffset={15} align="start">
<PopoverContent
side="right"
sideOffset={15}
align="start"
data-id="save-control-popover-content"
>
<Card className="border-none shadow-none">
<CardContent className="p-4">
<div className="grid gap-3">

View File

@ -114,7 +114,7 @@ function AgentDetailContent({ agent }: { agent: AgentDetailResponse }) {
{agent.description}
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:p-0">
<div className="border-t border-gray-300 px-4 py-5 sm:p-0">
<dl className="sm:divide-y sm:divide-gray-200">
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt className="flex items-center text-sm font-medium text-gray-500">

View File

@ -1,21 +1,23 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { IconRefresh, IconCoin } from "@/components/ui/icons";
import { IconRefresh } from "@/components/ui/icons";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
const api = new AutoGPTServerAPI();
export default function CreditButton() {
const [credit, setCredit] = useState<number | null>(null);
const api = new AutoGPTServerAPI();
const fetchCredit = async () => {
const fetchCredit = useCallback(async () => {
const response = await api.getUserCredit();
setCredit(response.credits);
};
}, []);
useEffect(() => {
fetchCredit();
}, [api]);
}, [fetchCredit]);
return (
credit !== null && (

View File

@ -9,7 +9,6 @@ import {
BlockIOStringSubSchema,
BlockIONumberSubSchema,
BlockIOBooleanSubSchema,
BlockIOCredentialsSubSchema,
} from "@/lib/autogpt-server-api/types";
import React, { FC, useCallback, useEffect, useState } from "react";
import { Button } from "./ui/button";
@ -110,6 +109,7 @@ export const NodeGenericInputField: FC<{
className,
displayName,
}) => {
className = cn(className, "my-2");
displayName ||= propSchema.title || beautifyString(propKey);
if ("allOf" in propSchema) {
@ -322,6 +322,10 @@ const NodeCredentialsInput: FC<{
);
};
const InputRef = (value: any): ((el: HTMLInputElement | null) => void) => {
return (el) => el && value && (el.value = value);
};
const NodeKeyValueInput: FC<{
nodeId: string;
selfKey: string;
@ -348,7 +352,7 @@ const NodeKeyValueInput: FC<{
const defaultEntries = new Map(
Object.entries(entries ?? schema.default ?? {}),
);
const prefix = getEntryKey("");
const prefix = `${selfKey}_#_`;
connections
.filter((c) => c.targetHandle.startsWith(prefix))
.map((c) => c.targetHandle.slice(prefix.length))
@ -368,6 +372,7 @@ const NodeKeyValueInput: FC<{
function updateKeyValuePairs(newPairs: typeof keyValuePairs) {
setKeyValuePairs(newPairs);
handleInputChange(
selfKey,
newPairs.reduce((obj, { key, value }) => ({ ...obj, [key]: value }), {}),
@ -394,27 +399,26 @@ const NodeKeyValueInput: FC<{
}
return (
<div className={cn(className, "flex flex-col")}>
{displayName && <strong>{displayName}</strong>}
<div
className={cn(className, keyValuePairs.length > 0 ? "flex flex-col" : "")}
>
<div>
{keyValuePairs.map(({ key, value }, index) => (
<div key={getEntryKey(key)}>
{key && (
<NodeHandle
keyName={getEntryKey(key)}
schema={{ type: "string" }}
isConnected={isConnected(key)}
isRequired={false}
side="left"
/>
)}
<NodeHandle
keyName={getEntryKey(key)}
schema={{ type: "string" }}
isConnected={isConnected(key)}
isRequired={false}
side="left"
/>
{!isConnected(key) && (
<div className="nodrag mb-2 flex items-center space-x-2">
<Input
type="text"
placeholder="Key"
value={key}
onChange={(e) =>
ref={InputRef(key ?? "")}
onBlur={(e) =>
updateKeyValuePairs(
keyValuePairs.toSpliced(index, 1, {
key: e.target.value,
@ -426,7 +430,7 @@ const NodeKeyValueInput: FC<{
<Input
type="text"
placeholder="Value"
defaultValue={value ?? ""}
ref={InputRef(value ?? "")}
onBlur={(e) =>
updateKeyValuePairs(
keyValuePairs.toSpliced(index, 1, {
@ -455,7 +459,11 @@ const NodeKeyValueInput: FC<{
</div>
))}
<Button
className="w-full"
className="rounded-xl bg-gray-200 font-normal text-black hover:text-white"
disabled={
keyValuePairs.length > 0 &&
!keyValuePairs[keyValuePairs.length - 1].key
}
onClick={() =>
updateKeyValuePairs(keyValuePairs.concat({ key: "", value: "" }))
}
@ -559,6 +567,7 @@ const NodeArrayInput: FC<{
);
})}
<Button
className="w-[183p] rounded-xl bg-gray-200 font-normal text-black hover:text-white"
onClick={() =>
handleInputChange(selfKey, [...entries, isItemObject ? {} : ""])
}
@ -616,13 +625,15 @@ const NodeStringInput: FC<{
<Input
type="text"
id={selfKey}
defaultValue={schema.secret && value ? "********" : value}
ref={InputRef(
schema.secret && value ? "*".repeat(value.length) : value,
)}
readOnly={schema.secret}
placeholder={
schema?.placeholder || `Enter ${beautifyString(displayName)}`
}
onBlur={(e) => handleInputChange(selfKey, e.target.value)}
className="pr-8 read-only:cursor-pointer read-only:text-gray-500"
className="rounded-xl pr-8 read-only:cursor-pointer read-only:text-gray-500"
/>
<Button
variant="ghost"
@ -674,8 +685,7 @@ export const NodeTextBoxInput: FC<{
schema?.placeholder || `Enter ${beautifyString(displayName)}`
}
onChange={(e) => handleInputChange(selfKey, e.target.value)}
onBlur={(e) => handleInputChange(selfKey, e.target.value)}
className="h-full w-full resize-none overflow-hidden border-none bg-transparent text-lg text-black outline-none"
className="h-full w-full resize-none overflow-hidden rounded-xl border-none bg-transparent text-lg text-black outline-none"
style={{
fontSize: "min(1em, 16px)",
lineHeight: "1.2",
@ -712,7 +722,7 @@ const NodeNumberInput: FC<{
<Input
type="number"
id={selfKey}
defaultValue={value}
ref={InputRef(value)}
onBlur={(e) => handleInputChange(selfKey, parseFloat(e.target.value))}
placeholder={
schema.placeholder || `Enter ${beautifyString(displayName)}`

View File

@ -3,6 +3,7 @@ import Shepherd from "shepherd.js";
import "shepherd.js/dist/css/shepherd.css";
export const startTutorial = (
emptyNodeList: (forceEmpty: boolean) => boolean,
setPinBlocksPopover: (value: boolean) => void,
setPinSavePopover: (value: boolean) => void,
) => {
@ -138,10 +139,14 @@ export const startTutorial = (
injectStyles();
const warningText = emptyNodeList(false)
? ""
: "<br/><br/><b>Caution: Clicking next will start a tutorial and will clear the current flow.</b>";
tour.addStep({
id: "starting-step",
title: "Welcome to the Tutorial",
text: "This is the AutoGPT builder!",
text: `This is the AutoGPT builder! ${warningText}`,
buttons: [
{
text: "Skip Tutorial",
@ -153,7 +158,10 @@ export const startTutorial = (
},
{
text: "Next",
action: tour.next,
action: () => {
emptyNodeList(true);
tour.next();
},
},
],
});
@ -202,7 +210,7 @@ export const startTutorial = (
id: "focus-new-block",
title: "New Block",
text: "This is the Calculator Block! Let's go over how it works.",
attachTo: { element: `[data-id="custom-node-1"]`, on: "left" },
attachTo: { element: `[data-id="custom-node-1"]`, on: "top" },
beforeShowPromise: () => waitForElement('[data-id="custom-node-1"]'),
buttons: [
{
@ -213,7 +221,9 @@ export const startTutorial = (
when: {
show: () => {
setPinBlocksPopover(false);
fitViewToScreen();
setTimeout(() => {
fitViewToScreen();
}, 100);
},
},
});
@ -253,48 +263,10 @@ export const startTutorial = (
});
tour.addStep({
id: "select-operation",
title: "Select Operation",
text: 'Select a mathematical operation to perform. Lets choose "Add" for now.',
attachTo: { element: ".mt-1.mb-2", on: "right" },
buttons: [
{
text: "Back",
action: tour.back,
},
{
text: "Next",
action: tour.next,
},
],
when: {
show: () => tour.modal.hide(),
hide: () => tour.modal.show(),
},
});
tour.addStep({
id: "enter-number-1",
title: "Enter a Number",
text: "Enter a number here to try the Calculator Block!",
attachTo: { element: "#a", on: "right" },
buttons: [
{
text: "Back",
action: tour.back,
},
{
text: "Next",
action: tour.next,
},
],
});
tour.addStep({
id: "enter-number-2",
title: "Enter Another Number",
text: "Enter another number here!",
attachTo: { element: "#b", on: "right" },
id: "select-operation-and-input",
title: "Select Operation and Input Numbers",
text: "Select any mathematical operation you'd like to perform, and enter numbers in both input fields.",
attachTo: { element: '[data-id="input-handles"]', on: "right" },
buttons: [
{
text: "Back",
@ -331,60 +303,20 @@ export const startTutorial = (
});
tour.addStep({
id: "enter-agent-name",
title: "Enter Agent Name",
text: 'Please enter any agent name, here we can just call it "Tutorial" if you\'d like.',
attachTo: {
element: '[data-id="save-control-name-input"]',
on: "bottom",
},
buttons: [
{
text: "Back",
action: tour.back,
},
{
text: "Next",
action: tour.next,
},
],
beforeShowPromise: () =>
waitForElement('[data-id="save-control-name-input"]'),
});
tour.addStep({
id: "enter-agent-description",
title: "Enter Agent Description",
text: "This is where you can add a description if you'd like, but that is optional.",
attachTo: {
element: '[data-id="save-control-description-input"]',
on: "bottom",
},
buttons: [
{
text: "Back",
action: tour.back,
},
{
text: "Next",
action: tour.next,
},
],
});
tour.addStep({
id: "save-agent",
id: "save-agent-details",
title: "Save the Agent",
text: "Now, let's save the agent by clicking the 'Save agent' button.",
text: "Enter a name for your agent, add an optional description, and then click 'Save agent' to save your flow.",
attachTo: {
element: '[data-id="save-control-save-agent"]',
on: "top",
element: '[data-id="save-control-popover-content"]',
on: "bottom",
},
buttons: [],
beforeShowPromise: () =>
waitForElement('[data-id="save-control-popover-content"]'),
advanceOn: {
selector: '[data-id="save-control-save-agent"]',
event: "click",
},
buttons: [],
when: {
hide: () => setPinSavePopover(false),
},
@ -421,7 +353,9 @@ export const startTutorial = (
},
buttons: [],
beforeShowPromise: () =>
waitForElement('[data-id^="badge-"][data-id$="-QUEUED"]'),
waitForElement('[data-id^="badge-"][data-id$="-QUEUED"]').then(
fitViewToScreen,
),
when: {
show: () => {
waitForElement('[data-id^="badge-"][data-id$="-COMPLETED"]').then(

View File

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl bg-white text-neutral-950 shadow dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
"rounded-xl border border-gray-300 bg-white text-neutral-950 shadow",
className,
)}
{...props}

View File

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-gray-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300",
"flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:border-gray-400 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
type == "file" ? "pb-0.5 pt-1.5" : "", // fix alignment
className,
)}

View File

@ -46,13 +46,15 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
const ImageRenderer: React.FC<{ imageUrl: string }> = ({ imageUrl }) => (
<div className="w-full p-2">
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
<picture>
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
</picture>
</div>
);

View File

@ -747,6 +747,9 @@ export default function useAgentGraph(
api,
nodes,
edges,
pathname,
router,
searchParams,
savedAgent,
agentName,
agentDescription,

View File

@ -26,13 +26,10 @@ export function deepEquals(x: any, y: any): boolean {
ty = typeof y;
const res =
x &&
y &&
tx === ty &&
(tx === "object"
x && y && tx === ty && tx === "object"
? ok(x).length === ok(y).length &&
ok(x).every((key) => deepEquals(x[key], y[key]))
: x === y);
: x === y;
return res;
}
@ -107,6 +104,7 @@ const exceptionMap: Record<string, string> = {
Url: "URL",
Http: "HTTP",
Json: "JSON",
Ai: "AI",
};
const applyExceptions = (str: string): string => {
@ -180,21 +178,22 @@ export function removeEmptyStringsAndNulls(obj: any): any {
}
export const categoryColorMap: Record<string, string> = {
AI: "bg-orange-300/[.7]",
SOCIAL: "bg-yellow-300/[.7]",
TEXT: "bg-green-300/[.7]",
SEARCH: "bg-blue-300/[.7]",
BASIC: "bg-purple-300/[.7]",
INPUT: "bg-cyan-300/[.7]",
OUTPUT: "bg-red-300/[.7]",
LOGIC: "bg-teal-300/[.7]",
AI: "bg-orange-300",
SOCIAL: "bg-yellow-300",
TEXT: "bg-green-300",
SEARCH: "bg-blue-300",
BASIC: "bg-purple-300",
INPUT: "bg-cyan-300",
OUTPUT: "bg-red-300",
LOGIC: "bg-teal-300",
DEVELOPER_TOOLS: "bg-fuchsia-300",
};
export function getPrimaryCategoryColor(categories: Category[]): string {
if (categories.length === 0) {
return "bg-gray-300/[.7]";
return "bg-gray-300";
}
return categoryColorMap[categories[0].category] || "bg-gray-300/[.7]";
return categoryColorMap[categories[0].category] || "bg-gray-300";
}
export function filterBlocksByType<T>(

File diff suppressed because it is too large Load Diff