diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 82383b102c..a6fd4678bb 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -4,55 +4,17 @@ import { useEffect, useState } from 'react' import { formatDistanceToNow } from 'date-fns' import { ArrowLeft, - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, + ChartNoAxesColumn, ChevronDown, - Clock, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Eye, - FileText, - Folder, Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, Linkedin, Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, Star, - Target, - TrendingUp, - Twitter, User, - Users, - Workflow, - Wrench, - Zap, } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import ReactMarkdown from 'react-markdown' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/emcn' import { DropdownMenu, DropdownMenuContent, @@ -69,47 +31,6 @@ import { getBlock } from '@/blocks/registry' const logger = createLogger('TemplateDetails') -// Icon mapping -const iconMap = { - FileText, - NotebookPen, - BookOpen, - BarChart3, - LineChart, - TrendingUp, - Target, - Database, - Server, - Cloud, - Folder, - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - HeadphonesIcon, - Users, - Settings, - Wrench, - Bot, - Brain, - Cpu, - Code, - Zap, - Workflow, - Search, - Play, - Layers, - Lightbulb, - Globe, - Award, -} - interface TemplateDetailsProps { isWorkspaceContext?: boolean } @@ -313,7 +234,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return (
-

Loading template...

+

Loading template...

) @@ -323,8 +244,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return (
-

Template Not Found

-

The template you're looking for doesn't exist.

+

Template Not Found

+

+ The template you're looking for doesn't exist. +

) @@ -335,8 +258,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return (
-
⚠️ No Workflow Data
-
This template doesn't contain workflow state data.
+
⚠️ No Workflow Data
+
+ This template doesn't contain workflow state data. +
) @@ -359,8 +284,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return (
-
⚠️ Preview Error
-
Unable to render workflow preview
+
⚠️ Preview Error
+
Unable to render workflow preview
) @@ -374,6 +299,25 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template router.push('/templates') } } + /** + * Intercepts wheel events over the workflow preview so that the page handles scrolling + * instead of the underlying canvas. We stop propagation in the capture phase to prevent + * React Flow from consuming the event, but intentionally avoid preventDefault so the + * browser can perform its normal scroll behavior. + * + * We allow zoom gestures (Ctrl/Cmd + wheel) to pass through unmodified. + * + * @param event - The wheel event fired when the user scrolls over the preview area. + */ + const handleCanvasWheelCapture = (event: React.WheelEvent) => { + // Allow pinch/zoom gestures (e.g., ctrl/cmd + wheel) to continue to the canvas. + if (event.ctrlKey || event.metaKey) { + return + } + + // Prevent React Flow from handling the wheel; let the page scroll naturally. + event.stopPropagation() + } const handleStarToggle = async () => { if (isStarring || !currentUserId) return @@ -576,99 +520,54 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return (
- {/* Header */} -
-
- {/* Back button */} - - - {/* Template header */} -
-
- {/* Icon */} - - {/* Title and description */} -
-

{template.name}

- {template.details?.tagline && ( -

- {template.details.tagline} -

- )} - {/* Tags */} - {template.tags && template.tags.length > 0 && ( -
- {template.tags.map((tag, index) => ( - - {tag} - - ))} -
- )} -
-
+
+
+ {/* Top bar with back button and action buttons */} +
+ {/* Back button */} + {/* Action buttons */} -
- {/* Super user approve/reject buttons for pending templates */} +
+ {/* Approve/Reject buttons for super users */} {isSuperUser && template.status === 'pending' && ( <> )} - {/* Star button - only for logged-in non-owners and non-pending templates */} - {currentUserId && !canEditTemplate && template.status !== 'pending' && ( - - )} - {/* Edit button - for template owners */} {canEditTemplate && currentUserId && ( <> {(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? ( ) : ( @@ -721,6 +617,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template <> {!currentUserId ? ( ) : isWorkspaceContext ? ( ) : ( @@ -790,132 +685,147 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
- {/* Tags */} -
- {/* Views */} -
- - {template.views} views -
+ {/* Template name */} +

{template.name}

- {/* Stars */} -
- - {starCount} stars -
- - {/* Author */} -
- - by {template.creator?.name || 'Unknown'} -
+ {/* Template tagline */} + {template.details?.tagline && ( +

+ {template.details.tagline} +

+ )} - {/* Author Type - show if organization */} - {template.creator?.referenceType === 'organization' && ( -
- - Organization + {/* Creator and stats row */} +
+ {/* Star icon and count */} + + {starCount} + + {/* Users icon and count */} + + {template.views} + + {/* Vertical divider */} +
+ + {/* Creator profile pic */} + {template.creator?.profileImageUrl ? ( +
+ {template.creator.name}
- )} - - {/* Last Updated */} - {template.updatedAt && ( -
- - - Last updated{' '} - {formatDistanceToNow(new Date(template.updatedAt), { - addSuffix: true, - })} - + ) : ( +
+
)} + {/* Creator name */} + + {template.creator?.name || 'Unknown'} +
-
-
- - {/* Workflow preview */} -
-
-

- Workflow Preview -

-
{renderWorkflowPreview()}
+ {/* Credentials needed */} {Array.isArray(template.requiredCredentials) && template.requiredCredentials.length > 0 && ( -
-

- Credentials Needed -

-
    - {template.requiredCredentials.map((cred: CredentialRequirement, idx: number) => { - // Get block name from registry or format blockType +

    + Credentials needed:{' '} + {template.requiredCredentials + .map((cred: CredentialRequirement) => { const blockName = getBlock(cred.blockType)?.name || cred.blockType.charAt(0).toUpperCase() + cred.blockType.slice(1) const alreadyHasBlock = cred.label .toLowerCase() .includes(` for ${blockName.toLowerCase()}`) - const text = alreadyHasBlock ? cred.label : `${cred.label} for ${blockName}` - return

  • {text}
  • + return alreadyHasBlock ? cred.label : `${cred.label} for ${blockName}` + }) + .join(', ')} +

    + )} + + {/* Canvas preview */} +
    + {renderWorkflowPreview()} + + {/* Last updated overlay */} + {template.updatedAt && ( +
    + + Last updated{' '} + {formatDistanceToNow(new Date(template.updatedAt), { + addSuffix: true, })} -
+
)} +
{/* About this Workflow */} {template.details?.about && ( -
-

+
+

About this Workflow

( -

+

{children}

), h1: ({ children }) => ( -

+

{children}

), h2: ({ children }) => ( -

+

{children}

), h3: ({ children }) => ( -

+

{children}

), h4: ({ children }) => ( -

+

{children}

), ul: ({ children }) => ( -
    +
      {children}
    ), ol: ({ children }) => ( -
      +
        {children}
      ), li: ({ children }) =>
    1. {children}
    2. , code: ({ inline, children }: any) => inline ? ( - + {children} ) : ( - + {children} ), @@ -924,19 +834,17 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template href={href} target='_blank' rel='noopener noreferrer' - className='text-[#3B82F6] underline-offset-2 transition-colors hover:text-[#60A5FA] hover:underline dark:text-[#60A5FA] dark:hover:text-[#93C5FD]' + className='text-blue-600 underline-offset-2 transition-colors hover:text-blue-500 hover:underline dark:text-blue-400 dark:hover:text-blue-300' > {children} ), strong: ({ children }) => ( - + {children} ), - em: ({ children }) => ( - {children} - ), + em: ({ children }) => {children}, }} > {template.details.about} @@ -945,17 +853,21 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
)} - {/* Creator Profile */} - {template.creator && ( -
-

- About the Creator -

-
- {/* Profile Picture */} -
+ {/* About the Creator */} + {template.creator && + (template.creator.details?.about || + template.creator.details?.xUrl || + template.creator.details?.linkedinUrl || + template.creator.details?.websiteUrl || + template.creator.details?.contactEmail) && ( +
+

+ About the Creator +

+
+ {/* Creator profile image */} {template.creator.profileImageUrl ? ( -
+
{template.creator.name}
) : ( -
- +
+
)} -
- {/* Creator Info */} -
-

- {template.creator.name} -

- {template.creator.details?.about && ( -

- {template.creator.details.about} -

- )} + {/* Creator details */} +
+
+

+ {template.creator.name} +

- {/* Social Links */} - {(template.creator.details?.xUrl || - template.creator.details?.linkedinUrl || - template.creator.details?.websiteUrl || - template.creator.details?.contactEmail) && ( -
- {template.creator.details.xUrl && ( - - - X - - )} - {template.creator.details.linkedinUrl && ( - - - LinkedIn - - )} - {template.creator.details.websiteUrl && ( - - - Website - - )} - {template.creator.details.contactEmail && ( - - - Contact - - )} + {/* Social links */} +
+ {template.creator.details?.websiteUrl && ( + + + + )} + {template.creator.details?.xUrl && ( + + + + + + )} + {template.creator.details?.linkedinUrl && ( + + + + )} + {template.creator.details?.contactEmail && ( + + + + )} +
- )} + + {/* Creator bio */} + {template.creator.details?.about && ( +
+ ( +

+ {children} +

+ ), + a: ({ href, children }) => ( + + {children} + + ), + strong: ({ children }) => ( + + {children} + + ), + }} + > + {template.creator.details.about} +
+
+ )} +
-
- )} + )}
diff --git a/apps/sim/app/templates/components/template-card.tsx b/apps/sim/app/templates/components/template-card.tsx index 89ca38754d..2d930f067a 100644 --- a/apps/sim/app/templates/components/template-card.tsx +++ b/apps/sim/app/templates/components/template-card.tsx @@ -8,6 +8,7 @@ import { Brain, Briefcase, Calculator, + ChartNoAxesColumn, Cloud, Code, Cpu, @@ -41,7 +42,7 @@ import { Wrench, Zap, } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { Badge } from '@/components/ui/badge' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' @@ -115,6 +116,7 @@ interface TemplateCardProps { title: string description: string author: string + authorImageUrl?: string | null usageCount: string stars?: number blocks?: string[] @@ -126,6 +128,11 @@ interface TemplateCardProps { isStarred?: boolean onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void isAuthenticated?: boolean + onTemplateUsed?: () => void + status?: 'pending' | 'approved' | 'rejected' + isSuperUser?: boolean + onApprove?: (templateId: string) => void + onReject?: (templateId: string) => void } // Skeleton component for loading states @@ -215,6 +222,7 @@ export function TemplateCard({ title, description, author, + authorImageUrl, usageCount, stars = 0, blocks = [], @@ -224,13 +232,21 @@ export function TemplateCard({ isStarred = false, onStarChange, isAuthenticated = true, + onTemplateUsed, + status, + isSuperUser, + onApprove, + onReject, }: TemplateCardProps) { const router = useRouter() + const params = useParams() // Local state for optimistic updates const [localIsStarred, setLocalIsStarred] = useState(isStarred) const [localStarCount, setLocalStarCount] = useState(stars) const [isStarLoading, setIsStarLoading] = useState(false) + const [isApproving, setIsApproving] = useState(false) + const [isRejecting, setIsRejecting] = useState(false) // Extract block types from state if provided, otherwise use the blocks prop // Filter out starter blocks in both cases and sort for consistent rendering @@ -238,6 +254,9 @@ export function TemplateCard({ ? extractBlockTypesFromState(state) : blocks.filter((blockType) => blockType !== 'starter').sort() + // Determine if we're in a workspace context + const workspaceId = params?.workspaceId as string | undefined + // Handle star toggle with optimistic updates const handleStarClick = async (e: React.MouseEvent) => { e.stopPropagation() @@ -291,20 +310,119 @@ export function TemplateCard({ } } - // Handle use click - just navigate to detail page + /** + * Handles template use action + * - In workspace context: Creates workflow instance via API + * - Outside workspace: Navigates to template detail page + */ const handleUseClick = async (e: React.MouseEvent) => { e.stopPropagation() - router.push(`/templates/${id}`) + + if (workspaceId) { + // Workspace context: Use API to create workflow instance + try { + const response = await fetch(`/api/templates/${id}/use`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }) + + if (response.ok) { + const data = await response.json() + logger.info('Template use API response:', data) + + if (!data.workflowId) { + logger.error('No workflowId returned from API:', data) + return + } + + const workflowUrl = `/workspace/${workspaceId}/w/${data.workflowId}` + logger.info('Template used successfully, navigating to:', workflowUrl) + + if (onTemplateUsed) { + onTemplateUsed() + } + + window.location.href = workflowUrl + } else { + const errorText = await response.text() + logger.error('Failed to use template:', response.statusText, errorText) + } + } catch (error) { + logger.error('Error using template:', error) + } + } else { + // Non-workspace context: Navigate to template detail page + router.push(`/templates/${id}`) + } } + /** + * Handles card click navigation + * - In workspace context: Navigate to workspace template detail + * - Outside workspace: Navigate to global template detail + */ const handleCardClick = (e: React.MouseEvent) => { - // Don't navigate if clicking on action buttons const target = e.target as HTMLElement if (target.closest('button') || target.closest('[data-action]')) { return } - router.push(`/templates/${id}`) + if (workspaceId) { + router.push(`/workspace/${workspaceId}/templates/${id}`) + } else { + router.push(`/templates/${id}`) + } + } + + /** + * Handles template approval (super user only) + */ + const handleApprove = async (e: React.MouseEvent) => { + e.stopPropagation() + if (isApproving || !onApprove) return + + setIsApproving(true) + try { + const response = await fetch(`/api/templates/${id}/approve`, { + method: 'POST', + }) + + if (response.ok) { + onApprove(id) + } else { + logger.error('Failed to approve template:', response.statusText) + } + } catch (error) { + logger.error('Error approving template:', error) + } finally { + setIsApproving(false) + } + } + + /** + * Handles template rejection (super user only) + */ + const handleReject = async (e: React.MouseEvent) => { + e.stopPropagation() + if (isRejecting || !onReject) return + + setIsRejecting(true) + try { + const response = await fetch(`/api/templates/${id}/reject`, { + method: 'POST', + }) + + if (response.ok) { + onReject(id) + } else { + logger.error('Failed to reject template:', response.statusText) + } + } catch (error) { + logger.error('Error rejecting template:', error) + } finally { + setIsRejecting(false) + } } return ( @@ -330,29 +448,57 @@ export function TemplateCard({ {/* Actions */}
- {/* Star button - only for authenticated users */} - {isAuthenticated && ( - + + + + ) : ( + <> + {/* Star button - only for authenticated users */} + {isAuthenticated && ( + )} - /> + + )} -
@@ -387,10 +533,16 @@ export function TemplateCard({ {/* Bottom section */}
- by + {authorImageUrl ? ( +
+ {author} +
+ ) : ( + + )} {author} - + {usageCount} {/* Stars section - hidden on smaller screens when space is constrained */}
diff --git a/apps/sim/app/templates/navigation-tabs.tsx b/apps/sim/app/templates/navigation-tabs.tsx deleted file mode 100644 index f3a6271f10..0000000000 --- a/apps/sim/app/templates/navigation-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from '@/lib/utils' - -interface NavigationTab { - id: string - label: string - count?: number -} - -interface NavigationTabsProps { - tabs: NavigationTab[] - activeTab?: string - onTabClick?: (tabId: string) => void - className?: string -} - -export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) { - return ( -
- {tabs.map((tab, index) => ( - - ))} -
- ) -} diff --git a/apps/sim/app/templates/template-card.tsx b/apps/sim/app/templates/template-card.tsx deleted file mode 100644 index 03f5d65234..0000000000 --- a/apps/sim/app/templates/template-card.tsx +++ /dev/null @@ -1,564 +0,0 @@ -import { useState } from 'react' -import { - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - Zap, -} from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { createLogger } from '@/lib/logs/console/logger' -import { cn } from '@/lib/utils' -import { getBlock } from '@/blocks/registry' - -const logger = createLogger('TemplateCard') - -// Icon mapping for template icons -const iconMap = { - // Content & Documentation - FileText, - NotebookPen, - BookOpen, - Edit, - - // Analytics & Charts - BarChart3, - LineChart, - TrendingUp, - Target, - - // Database & Storage - Database, - Server, - Cloud, - Folder, - - // Marketing & Communication - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - - // Sales & Finance - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - - // Support & Service - HeadphonesIcon, - User, - Users, - Settings, - Wrench, - - // AI & Technology - Bot, - Brain, - Cpu, - Code, - Zap, - - // Workflow & Process - Workflow, - Search, - Play, - Layers, - - // General - Lightbulb, - Star, - Globe, - Award, -} - -interface TemplateCardProps { - id: string - title: string - description: string - author: string - usageCount: string - stars?: number - blocks?: string[] - onClick?: () => void - className?: string - // Add state prop to extract block types - state?: { - blocks?: Record - } - isStarred?: boolean - // Optional callback when template is successfully used (for closing modals, etc.) - onTemplateUsed?: () => void - // Callback when star state changes (for parent state updates) - onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void - // Super user props for approval - status?: 'pending' | 'approved' | 'rejected' - isSuperUser?: boolean - onApprove?: (templateId: string) => void - onReject?: (templateId: string) => void -} - -// Skeleton component for loading states -export function TemplateCardSkeleton({ className }: { className?: string }) { - return ( -
- {/* Left side - Info skeleton */} -
- {/* Top section skeleton */} -
-
-
- {/* Icon skeleton */} -
- {/* Title skeleton */} -
-
- - {/* Star and Use button skeleton */} -
-
-
-
-
- - {/* Description skeleton */} -
-
-
-
-
-
- - {/* Bottom section skeleton */} -
-
-
-
-
-
- {/* Stars section - hidden on smaller screens */} -
-
-
-
-
-
-
- - {/* Right side - Block Icons skeleton */} -
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} -
-
- ) -} - -// Utility function to extract block types from workflow state -const extractBlockTypesFromState = (state?: { - blocks?: Record -}): string[] => { - if (!state?.blocks) return [] - - // Get unique block types from the state, excluding starter blocks - // Sort the keys to ensure consistent ordering between server and client - const blockTypes = Object.keys(state.blocks) - .sort() // Sort keys to ensure consistent order - .map((key) => state.blocks![key].type) - .filter((type) => type !== 'starter') - return [...new Set(blockTypes)] -} - -// Utility function to get block display name -const getBlockDisplayName = (blockType: string): string => { - const block = getBlock(blockType) - return block?.name || blockType -} - -// Utility function to get the full block config for colored icon display -const getBlockConfig = (blockType: string) => { - const block = getBlock(blockType) - return block -} - -export function TemplateCard({ - id, - title, - description, - author, - usageCount, - stars = 0, - blocks = [], - onClick, - className, - state, - isStarred = false, - onTemplateUsed, - onStarChange, - status, - isSuperUser, - onApprove, - onReject, -}: TemplateCardProps) { - const router = useRouter() - const params = useParams() - const [isApproving, setIsApproving] = useState(false) - const [isRejecting, setIsRejecting] = useState(false) - - // Local state for optimistic updates - const [localIsStarred, setLocalIsStarred] = useState(isStarred) - const [localStarCount, setLocalStarCount] = useState(stars) - const [isStarLoading, setIsStarLoading] = useState(false) - - // Extract block types from state if provided, otherwise use the blocks prop - // Filter out starter blocks in both cases and sort for consistent rendering - const blockTypes = state - ? extractBlockTypesFromState(state) - : blocks.filter((blockType) => blockType !== 'starter').sort() - - // Handle star toggle with optimistic updates - const handleStarClick = async (e: React.MouseEvent) => { - e.stopPropagation() - - // Prevent multiple clicks while loading - if (isStarLoading) return - - setIsStarLoading(true) - - // Optimistic update - update UI immediately - const newIsStarred = !localIsStarred - const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1 - - setLocalIsStarred(newIsStarred) - setLocalStarCount(newStarCount) - - // Notify parent component immediately for optimistic update - if (onStarChange) { - onStarChange(id, newIsStarred, newStarCount) - } - - try { - const method = localIsStarred ? 'DELETE' : 'POST' - const response = await fetch(`/api/templates/${id}/star`, { method }) - - if (!response.ok) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Failed to toggle star:', response.statusText) - } - } catch (error) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Error toggling star:', error) - } finally { - setIsStarLoading(false) - } - } - - // Handle use template - const handleUseClick = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - const response = await fetch(`/api/templates/${id}/use`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId: params.workspaceId, - }), - }) - - if (response.ok) { - const data = await response.json() - logger.info('Template use API response:', data) - - if (!data.workflowId) { - logger.error('No workflowId returned from API:', data) - return - } - - const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}` - logger.info('Template used successfully, navigating to:', workflowUrl) - - // Call the callback if provided (for closing modals, etc.) - if (onTemplateUsed) { - onTemplateUsed() - } - - // Use window.location.href for more reliable navigation - window.location.href = workflowUrl - } else { - const errorText = await response.text() - logger.error('Failed to use template:', response.statusText, errorText) - } - } catch (error) { - logger.error('Error using template:', error) - } - } - - const handleCardClick = (e: React.MouseEvent) => { - // Don't navigate if clicking on action buttons - const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[data-action]')) { - return - } - - const workspaceId = params?.workspaceId as string - if (workspaceId) { - router.push(`/workspace/${workspaceId}/templates/${id}`) - } - } - - const handleApprove = async (e: React.MouseEvent) => { - e.stopPropagation() - if (isApproving || !onApprove) return - - setIsApproving(true) - try { - const response = await fetch(`/api/templates/${id}/approve`, { - method: 'POST', - }) - - if (response.ok) { - onApprove(id) - } - } catch (error) { - logger.error('Error approving template:', error) - } finally { - setIsApproving(false) - } - } - - const handleReject = async (e: React.MouseEvent) => { - e.stopPropagation() - if (isRejecting || !onReject) return - - setIsRejecting(true) - try { - const response = await fetch(`/api/templates/${id}/reject`, { - method: 'POST', - }) - - if (response.ok) { - onReject(id) - } - } catch (error) { - logger.error('Error rejecting template:', error) - } finally { - setIsRejecting(false) - } - } - - return ( -
- {/* Left side - Info */} -
- {/* Top section */} -
-
-
- {/* Template name */} -

- {title} -

-
- - {/* Actions */} -
- {/* Approve/Reject buttons for pending templates (super users only) */} - {isSuperUser && status === 'pending' ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- - {/* Description */} -

- {description} -

-
- - {/* Bottom section */} -
- by - {author} - - - {usageCount} - {/* Stars section - hidden on smaller screens when space is constrained */} -
- - - {localStarCount} -
-
-
- - {/* Right side - Block Icons */} -
- {blockTypes.length > 3 ? ( - <> - {/* Show first 2 blocks when there are more than 3 */} - {blockTypes.slice(0, 2).map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( -
-
- -
-
- ) - })} - {/* Show +n block for remaining blocks */} -
-
- +{blockTypes.length - 2} -
-
- - ) : ( - /* Show all blocks when 3 or fewer */ - blockTypes.map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( -
-
- -
-
- ) - }) - )} -
-
- ) -} diff --git a/apps/sim/app/templates/templates.tsx b/apps/sim/app/templates/templates.tsx index 536c4a88da..eb28fb5373 100644 --- a/apps/sim/app/templates/templates.tsx +++ b/apps/sim/app/templates/templates.tsx @@ -120,6 +120,7 @@ export default function Templates({ title={template.name} description={template.details?.tagline || ''} author={template.creator?.name || 'Unknown'} + authorImageUrl={template.creator?.profileImageUrl || null} usageCount={template.views.toString()} stars={template.stars} tags={template.tags} diff --git a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx index 8d00b9afcb..5acafd05b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Star, User } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' @@ -14,6 +14,7 @@ interface TemplateCardProps { title: string description: string author: string + authorImageUrl?: string | null usageCount: string stars?: number icon?: React.ReactNode | string @@ -138,11 +139,12 @@ function normalizeWorkflowState(input?: any): WorkflowState | null { return normalized } -export function TemplateCard({ +function TemplateCardInner({ id, title, description, author, + authorImageUrl, usageCount, stars = 0, icon, @@ -164,11 +166,38 @@ export function TemplateCard({ const [localStarCount, setLocalStarCount] = useState(stars) const [isStarLoading, setIsStarLoading] = useState(false) + // Memoize normalized workflow state to avoid recalculation on every render + const normalizedState = useMemo(() => normalizeWorkflowState(state), [state]) + + // Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport + const previewRef = useRef(null) + const [isInView, setIsInView] = useState(false) + + useEffect(() => { + if (!previewRef.current) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true) + observer.disconnect() + } + }, + { root: null, rootMargin: '200px', threshold: 0 } + ) + observer.observe(previewRef.current) + return () => observer.disconnect() + }, []) + // Extract block types from state if provided, otherwise use the blocks prop // Filter out starter blocks in both cases and sort for consistent rendering - const blockTypes = state - ? extractBlockTypesFromState(state) - : blocks.filter((blockType) => blockType !== 'starter').sort() + // Memoized to prevent recalculation on every render + const blockTypes = useMemo( + () => + state + ? extractBlockTypesFromState(state) + : blocks.filter((blockType) => blockType !== 'starter').sort(), + [state, blocks] + ) // Handle star toggle with optimistic updates const handleStarClick = async (e: React.MouseEvent) => { @@ -227,35 +256,42 @@ export function TemplateCard({ * Get the appropriate template detail page URL based on context. * If we're in a workspace context, navigate to the workspace template page. * Otherwise, navigate to the global template page. + * Memoized to avoid recalculation on every render. */ - const getTemplateUrl = () => { + const templateUrl = useMemo(() => { const workspaceId = params?.workspaceId as string | undefined if (workspaceId) { return `/workspace/${workspaceId}/templates/${id}` } return `/templates/${id}` - } + }, [params?.workspaceId, id]) /** * Handle use button click - navigate to template detail page */ - const handleUseClick = async (e: React.MouseEvent) => { - e.stopPropagation() - router.push(getTemplateUrl()) - } + const handleUseClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + router.push(templateUrl) + }, + [router, templateUrl] + ) /** * Handle card click - navigate to template detail page */ - const handleCardClick = (e: React.MouseEvent) => { - // Don't navigate if clicking on action buttons - const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[data-action]')) { - return - } + const handleCardClick = useCallback( + (e: React.MouseEvent) => { + // Don't navigate if clicking on action buttons + const target = e.target as HTMLElement + if (target.closest('button') || target.closest('[data-action]')) { + return + } - router.push(getTemplateUrl()) - } + router.push(templateUrl) + }, + [router, templateUrl] + ) return (
{/* Workflow Preview */} -
- {normalizeWorkflowState(state) ? ( +
+ {normalizedState && isInView ? ( {/* Creator Info */}
-
+ {authorImageUrl ? ( +
+ {author} +
+ ) : ( +
+ +
+ )} {author}
@@ -363,3 +410,5 @@ export function TemplateCard({
) } + +export const TemplateCard = memo(TemplateCardInner) diff --git a/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx b/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx index 91889daf5a..cf9a7eaaad 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Layout, Search } from 'lucide-react' -import { Button, Input } from '@/components/emcn' +import { Button } from '@/components/emcn' +import { Input } from '@/components/ui/input' import { createLogger } from '@/lib/logs/console/logger' import { TemplateCard, @@ -86,8 +87,9 @@ export default function Templates({ /** * Filter templates based on active tab and search query + * Memoized to prevent unnecessary recalculations on render */ - const getFilteredTemplates = () => { + const filteredTemplates = useMemo(() => { const query = searchQuery.toLowerCase() return templates.filter((template) => { @@ -117,14 +119,13 @@ export default function Templates({ return searchableText.includes(query) }) - } - - const filteredTemplates = getFilteredTemplates() + }, [templates, activeTab, searchQuery, currentUserId]) /** * Get empty state message based on current filters + * Memoized to prevent unnecessary recalculations on render */ - const getEmptyStateMessage = () => { + const emptyState = useMemo(() => { if (searchQuery) { return { title: 'No templates found', @@ -148,9 +149,7 @@ export default function Templates({ } return messages[activeTab as keyof typeof messages] || messages.gallery - } - - const emptyState = getEmptyStateMessage() + }, [searchQuery, activeTab]) return (
@@ -158,12 +157,12 @@ export default function Templates({
-
- +
+

Templates

-

+

Grab a template and start building, or make one from scratch.

@@ -213,19 +212,16 @@ export default function Templates({ )) ) : filteredTemplates.length === 0 ? ( -
+
-

- {emptyState.title} -

-

- {emptyState.description} -

+

{emptyState.title}

+

{emptyState.description}

) : ( filteredTemplates.map((template) => { const author = template.author || template.creator?.name || 'Unknown' + const authorImageUrl = template.creator?.profileImageUrl || null return (