diff --git a/app/hub/page.tsx b/app/hub/page.tsx index 7dc327d66..a3a8907a7 100644 --- a/app/hub/page.tsx +++ b/app/hub/page.tsx @@ -112,9 +112,6 @@ export default function HubPage() { debouncedSearchQuery, ]); - const topFeatured = featuredWorkflows[0]; - const carouselWorkflows = featuredWorkflows.slice(1); - useEffect(() => { const fetchWorkflows = async (): Promise => { try { @@ -161,25 +158,23 @@ export default function HubPage() { className="pointer-events-auto fixed inset-0 overflow-y-auto bg-sidebar [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref={scrollContainerRef} > - {/* Fixed dot pattern behind */} -
- {/* Fixed gradient overlay - fades as you scroll */} -
{/* start custom KeeperHub code */} {isLoading ? (

Loading workflows...

) : ( <> - + - +
+
+
+ +
+
+
-

diff --git a/keeperhub/components/hub/featured-carousel.tsx b/keeperhub/components/hub/featured-carousel.tsx index 9b0e7981a..df02bd17b 100644 --- a/keeperhub/components/hub/featured-carousel.tsx +++ b/keeperhub/components/hub/featured-carousel.tsx @@ -2,21 +2,26 @@ import { ChevronLeft, ChevronRight, Eye } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, - CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api, type SavedWorkflow } from "@/lib/api-client"; import { authClient, useSession } from "@/lib/auth-client"; import { WorkflowMiniMap } from "./workflow-mini-map"; +import { WorkflowNodeIcons } from "./workflow-node-icons"; type FeaturedCarouselProps = { workflows: SavedWorkflow[]; @@ -28,6 +33,23 @@ export function FeaturedCarousel({ workflows }: FeaturedCarouselProps) { const [duplicatingIds, setDuplicatingIds] = useState>(new Set()); const scrollRef = useRef(null); + const arrowVisibility = useMemo((): string => { + const count = workflows.length; + if (count > 4) { + return "flex"; + } + if (count > 3) { + return "flex lg:hidden"; + } + if (count > 2) { + return "flex md:hidden"; + } + if (count > 1) { + return "flex sm:hidden"; + } + return "hidden"; + }, [workflows.length]); + const scroll = useCallback((direction: "left" | "right") => { const container = scrollRef.current; if (!container) { @@ -77,10 +99,10 @@ export function FeaturedCarousel({ workflows }: FeaturedCarouselProps) { } return ( -
+

Featured

-
+
- + + + + + View Template + ); diff --git a/keeperhub/components/hub/hub-hero.tsx b/keeperhub/components/hub/hub-hero.tsx index 57dce4415..3751077fe 100644 --- a/keeperhub/components/hub/hub-hero.tsx +++ b/keeperhub/components/hub/hub-hero.tsx @@ -1,120 +1,30 @@ -"use client"; - -import { Eye } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { api, type SavedWorkflow } from "@/lib/api-client"; -import { authClient, useSession } from "@/lib/auth-client"; -import { WorkflowMiniMap } from "./workflow-mini-map"; - -type HubHeroProps = { - topWorkflow: SavedWorkflow | undefined; -}; - -export function HubHero({ topWorkflow }: HubHeroProps) { - const router = useRouter(); - const { data: session } = useSession(); - const [isDuplicating, setIsDuplicating] = useState(false); - - const handleDuplicate = async (workflowId: string): Promise => { - if (isDuplicating) { - return; - } - - setIsDuplicating(true); - - try { - if (!session?.user) { - await authClient.signIn.anonymous(); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const duplicated = await api.workflow.duplicate(workflowId); - toast.success("Workflow duplicated successfully"); - router.push(`/workflows/${duplicated.id}`); - } catch (error) { - console.error("Failed to duplicate workflow:", error); - toast.error( - error instanceof Error ? error.message : "Failed to duplicate workflow" - ); - } finally { - setIsDuplicating(false); - } - }; +import Image from "next/image"; +export function HubHero() { return ( -
-
+
+

- KeeperHub Web3 Automation + KeeperHub Web3 Templates

-

+

Browse ready-made workflow templates and community automations. Duplicate any template to get started in seconds.

- {topWorkflow && ( - -
- - {topWorkflow.category && ( - - {topWorkflow.category} - - )} -
- - {topWorkflow.protocol && ( -

- {topWorkflow.protocol} -

- )} - {topWorkflow.name} - {topWorkflow.description && ( - - {topWorkflow.description} - - )} -
- - - - - -
- )} +
+
+ +
+ +
); } diff --git a/keeperhub/components/hub/workflow-mini-map.tsx b/keeperhub/components/hub/workflow-mini-map.tsx index a485440bb..a8b72e6a1 100644 --- a/keeperhub/components/hub/workflow-mini-map.tsx +++ b/keeperhub/components/hub/workflow-mini-map.tsx @@ -1,20 +1,3 @@ -import type { LucideIcon } from "lucide-react"; -import { - Bot, - Box, - Clock, - Code, - GitBranch, - Hash, - Mail, - Play, - User, - Zap, -} from "lucide-react"; -import type { CSSProperties, ReactNode } from "react"; -import { DiscordIcon } from "@/keeperhub/plugins/discord/icon"; -import { Web3Icon } from "@/keeperhub/plugins/web3/icon"; -import { WebhookIcon } from "@/keeperhub/plugins/webhook/icon"; import type { WorkflowEdge, WorkflowNode } from "@/lib/workflow-store"; type WorkflowMiniMapProps = { @@ -34,8 +17,7 @@ type Bounds = { height: number; }; -const NODE_WIDTH = 80; -const NODE_HEIGHT = 80; +const FIXED_NODE_SIZE = 18; const PADDING = 20; function calculateBounds(nodes: WorkflowNode[]): Bounds { @@ -53,8 +35,8 @@ function calculateBounds(nodes: WorkflowNode[]): Bounds { const y = node.position?.y ?? 0; minX = Math.min(minX, x); minY = Math.min(minY, y); - maxX = Math.max(maxX, x + NODE_WIDTH); - maxY = Math.max(maxY, y + NODE_HEIGHT); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); } return { @@ -67,146 +49,32 @@ function calculateBounds(nodes: WorkflowNode[]): Bounds { }; } -type NodeIconType = - | LucideIcon - | (({ - className, - style, - }: { - className?: string; - style?: CSSProperties; - }) => ReactNode); - -function getTriggerIcon(triggerType: string | undefined): NodeIconType { - switch (triggerType) { - case "Schedule": - return Clock; - case "Webhook": - return Zap; - default: - return Play; - } -} - -function getActionIconBySlug(integrationType: string): NodeIconType { - switch (integrationType) { - case "web3": - return Web3Icon; - case "discord": - return DiscordIcon; - case "slack": - return Hash; - case "sendgrid": - case "resend": - return Mail; - case "webhook": - return WebhookIcon; - case "ai-gateway": - return Bot; - case "clerk": - return User; - default: - return Box; - } -} - -function getActionIconByLabel(lowerActionType: string): NodeIconType { - if ( - lowerActionType.includes("balance") || - lowerActionType.includes("transfer") || - lowerActionType.includes("contract") - ) { - return Web3Icon; - } - if (lowerActionType.includes("slack")) { - return Hash; - } - if (lowerActionType.includes("discord")) { - return DiscordIcon; - } - if ( - lowerActionType.includes("email") || - lowerActionType.includes("sendgrid") - ) { - return Mail; - } - if (lowerActionType.includes("webhook")) { - return WebhookIcon; - } - if (lowerActionType.includes("http") || lowerActionType.includes("request")) { - return Code; - } - if (lowerActionType === "condition") { - return GitBranch; - } - return Box; -} - -function getNodeIcon(node: WorkflowNode): NodeIconType { - const isTrigger = node.type === "trigger" || node.data?.type === "trigger"; - - if (isTrigger) { - const triggerType = node.data?.config?.triggerType as string | undefined; - return getTriggerIcon(triggerType); - } - - const actionType = node.data?.config?.actionType as string | undefined; - if (!actionType) { - return Box; - } - - if (actionType.includes("/")) { - const integrationType = actionType.split("/")[0]; - return getActionIconBySlug(integrationType); - } - - return getActionIconByLabel(actionType.toLowerCase()); -} - function MiniNode({ node, bounds, - scale, + posScale, offsetX, offsetY, }: { node: WorkflowNode; bounds: Bounds; - scale: number; + posScale: number; offsetX: number; offsetY: number; }) { - const x = ((node.position?.x ?? 0) - bounds.minX) * scale + offsetX; - const y = ((node.position?.y ?? 0) - bounds.minY) * scale + offsetY; - const nodeWidth = NODE_WIDTH * scale; - const nodeHeight = NODE_HEIGHT * scale; - const Icon = getNodeIcon(node); - - // Calculate icon size (50% of node size) - const iconSize = Math.min(nodeWidth, nodeHeight) * 0.5; + const x = ((node.position?.x ?? 0) - bounds.minX) * posScale + offsetX; + const y = ((node.position?.y ?? 0) - bounds.minY) * posScale + offsetY; return ( - - {/* Node background */} - - {/* Icon using foreignObject */} - -
- -
-
-
+ ); } @@ -214,14 +82,14 @@ function MiniEdge({ edge, nodes, bounds, - scale, + posScale, offsetX, offsetY, }: { edge: WorkflowEdge; nodes: WorkflowNode[]; bounds: Bounds; - scale: number; + posScale: number; offsetX: number; offsetY: number; }) { @@ -232,29 +100,29 @@ function MiniEdge({ return null; } - // Calculate center-right of source node const sourceX = - ((sourceNode.position?.x ?? 0) - bounds.minX + NODE_WIDTH) * scale + - offsetX; + ((sourceNode.position?.x ?? 0) - bounds.minX) * posScale + + offsetX + + FIXED_NODE_SIZE; const sourceY = - ((sourceNode.position?.y ?? 0) - bounds.minY + NODE_HEIGHT / 2) * scale + - offsetY; + ((sourceNode.position?.y ?? 0) - bounds.minY) * posScale + + offsetY + + FIXED_NODE_SIZE / 2; - // Calculate center-left of target node const targetX = - ((targetNode.position?.x ?? 0) - bounds.minX) * scale + offsetX; + ((targetNode.position?.x ?? 0) - bounds.minX) * posScale + offsetX; const targetY = - ((targetNode.position?.y ?? 0) - bounds.minY + NODE_HEIGHT / 2) * scale + - offsetY; + ((targetNode.position?.y ?? 0) - bounds.minY) * posScale + + offsetY + + FIXED_NODE_SIZE / 2; - // Create a smooth bezier curve const midX = (sourceX + targetX) / 2; return ( ); } @@ -262,28 +130,28 @@ function MiniEdge({ export function WorkflowMiniMap({ nodes, edges, - width = 200, - height = 120, + width = 280, + height = 160, className = "", }: WorkflowMiniMapProps) { if (!nodes || nodes.length === 0) { - // Empty state - show placeholder return ( ); @@ -291,18 +159,16 @@ export function WorkflowMiniMap({ const bounds = calculateBounds(nodes); - // Calculate scale to fit within the viewport with padding - const availableWidth = width - PADDING * 2; - const availableHeight = height - PADDING * 2; - const scaleX = availableWidth / bounds.width; - const scaleY = availableHeight / bounds.height; - const scale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down + const availW = width - PADDING * 2 - FIXED_NODE_SIZE; + const availH = height - PADDING * 2 - FIXED_NODE_SIZE; + const posScaleX = bounds.width > 0 ? availW / bounds.width : 1; + const posScaleY = bounds.height > 0 ? availH / bounds.height : 1; + const posScale = Math.min(posScaleX, posScaleY); - // Center the content - const scaledWidth = bounds.width * scale; - const scaledHeight = bounds.height * scale; - const offsetX = (width - scaledWidth) / 2; - const offsetY = (height - scaledHeight) / 2; + const scaledContentW = bounds.width * posScale + FIXED_NODE_SIZE; + const scaledContentH = bounds.height * posScale + FIXED_NODE_SIZE; + const offsetX = (width - scaledContentW) / 2; + const offsetY = (height - scaledContentH) / 2; return ( - {/* Render edges first (behind nodes) */} {edges.map((edge) => ( ))} - {/* Render nodes */} {nodes .filter((node) => node.type !== "add") .map((node) => ( @@ -336,7 +200,7 @@ export function WorkflowMiniMap({ node={node} offsetX={offsetX} offsetY={offsetY} - scale={scale} + posScale={posScale} /> ))} diff --git a/keeperhub/components/hub/workflow-node-icons.tsx b/keeperhub/components/hub/workflow-node-icons.tsx new file mode 100644 index 000000000..0f403b4aa --- /dev/null +++ b/keeperhub/components/hub/workflow-node-icons.tsx @@ -0,0 +1,195 @@ +import type { LucideIcon } from "lucide-react"; +import { + Bot, + Box, + Clock, + Code, + GitBranch, + Hash, + Mail, + Play, + User, + Zap, +} from "lucide-react"; +import type { CSSProperties, ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { DiscordIcon } from "@/keeperhub/plugins/discord/icon"; +import { Web3Icon } from "@/keeperhub/plugins/web3/icon"; +import { WebhookIcon } from "@/keeperhub/plugins/webhook/icon"; +import type { WorkflowNode } from "@/lib/workflow-store"; + +const MAX_VISIBLE = 4; + +type IconType = + | LucideIcon + | (({ + className, + style, + }: { + className?: string; + style?: CSSProperties; + }) => ReactNode); + +type IntegrationEntry = { + key: string; + label: string; + Icon: IconType; +}; + +function getTriggerInfo(triggerType: string | undefined): { + Icon: IconType; + label: string; +} { + switch (triggerType) { + case "Schedule": + return { Icon: Clock, label: "Schedule trigger" }; + case "Webhook": + return { Icon: Zap, label: "Webhook trigger" }; + default: + return { Icon: Play, label: "Manual trigger" }; + } +} + +function getActionInfo(actionType: string): { Icon: IconType; label: string } { + if (actionType.includes("/")) { + const slug = actionType.split("/")[0]; + switch (slug) { + case "web3": + return { Icon: Web3Icon, label: "Web3" }; + case "discord": + return { Icon: DiscordIcon, label: "Discord" }; + case "slack": + return { Icon: Hash, label: "Slack" }; + case "sendgrid": + return { Icon: Mail, label: "Email" }; + case "resend": + return { Icon: Mail, label: "Resend" }; + case "webhook": + return { Icon: WebhookIcon, label: "Webhook" }; + case "ai-gateway": + return { Icon: Bot, label: "AI Gateway" }; + case "clerk": + return { Icon: User, label: "Clerk" }; + default: + return { Icon: Box, label: slug }; + } + } + + const lower = actionType.toLowerCase(); + if ( + lower.includes("balance") || + lower.includes("transfer") || + lower.includes("contract") + ) { + return { Icon: Web3Icon, label: "Web3" }; + } + if (lower.includes("slack")) { + return { Icon: Hash, label: "Slack" }; + } + if (lower.includes("discord")) { + return { Icon: DiscordIcon, label: "Discord" }; + } + if (lower.includes("email") || lower.includes("sendgrid")) { + return { Icon: Mail, label: "Email" }; + } + if (lower.includes("webhook")) { + return { Icon: WebhookIcon, label: "Webhook" }; + } + if (lower.includes("http") || lower.includes("request")) { + return { Icon: Code, label: "HTTP Request" }; + } + if (lower === "condition") { + return { Icon: GitBranch, label: "Condition" }; + } + return { Icon: Box, label: actionType }; +} + +function getNodeEntry( + node: WorkflowNode +): { key: string; info: { Icon: IconType; label: string } } | null { + const isTrigger = node.type === "trigger" || node.data?.type === "trigger"; + + if (isTrigger) { + const triggerType = node.data?.config?.triggerType as string | undefined; + return { + key: `trigger-${triggerType ?? "manual"}`, + info: getTriggerInfo(triggerType), + }; + } + + const actionType = node.data?.config?.actionType as string | undefined; + if (!actionType) { + return null; + } + + const key = actionType.includes("/") + ? actionType.split("/")[0] + : actionType.toLowerCase(); + return { key, info: getActionInfo(actionType) }; +} + +function getUniqueIntegrations(nodes: WorkflowNode[]): IntegrationEntry[] { + const seen = new Set(); + const integrations: IntegrationEntry[] = []; + + for (const node of nodes) { + if (node.type === "add") { + continue; + } + + const entry = getNodeEntry(node); + if (!entry || seen.has(entry.key)) { + continue; + } + + seen.add(entry.key); + integrations.push({ + key: entry.key, + label: entry.info.label, + Icon: entry.info.Icon, + }); + } + + return integrations; +} + +type WorkflowNodeIconsProps = { + nodes: WorkflowNode[]; +}; + +export function WorkflowNodeIcons({ nodes }: WorkflowNodeIconsProps) { + const integrations = getUniqueIntegrations(nodes); + const visible = integrations.slice(0, MAX_VISIBLE); + const overflow = integrations.length - MAX_VISIBLE; + + return ( +
+ {visible.map(({ key, label, Icon }) => ( + + +
+ +
+
+ {label} +
+ ))} + {overflow > 0 && ( + + +
+ + +{overflow} + +
+
+ {overflow} more +
+ )} +
+ ); +} diff --git a/keeperhub/components/hub/workflow-template-grid.tsx b/keeperhub/components/hub/workflow-template-grid.tsx index 87342136a..7e59447b3 100644 --- a/keeperhub/components/hub/workflow-template-grid.tsx +++ b/keeperhub/components/hub/workflow-template-grid.tsx @@ -8,15 +8,20 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, - CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api, type SavedWorkflow } from "@/lib/api-client"; import { authClient, useSession } from "@/lib/auth-client"; import { WorkflowMiniMap } from "./workflow-mini-map"; +import { WorkflowNodeIcons } from "./workflow-node-icons"; type WorkflowTemplateGridProps = { workflows: SavedWorkflow[]; @@ -74,10 +79,10 @@ export function WorkflowTemplateGrid({ workflows }: WorkflowTemplateGridProps) { return ( -
+
{workflow.category && ( {workflow.category} )}
- + {workflow.protocol && (

{workflow.protocol} @@ -105,9 +110,10 @@ export function WorkflowTemplateGrid({ workflows }: WorkflowTemplateGridProps) { {workflow.description} )} + - - +

+ - + + + + + View Template + ); diff --git a/public/hub-graphic.png b/public/hub-graphic.png new file mode 100644 index 000000000..3dfc284e6 Binary files /dev/null and b/public/hub-graphic.png differ