improvement: templates styling (#1952)

This commit is contained in:
Emir Karabeg
2025-11-12 15:58:38 -08:00
committed by GitHub
parent 10288111a8
commit ead0db9d2a
7 changed files with 528 additions and 985 deletions
+258 -317
View File
@@ -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 (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<p className='text-muted-foreground'>Loading template...</p>
<p className='font-sans text-muted-foreground text-sm'>Loading template...</p>
</div>
</div>
)
@@ -323,8 +244,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
<h1 className='mb-4 font-sans font-semibold text-2xl'>Template Not Found</h1>
<p className='font-sans text-muted-foreground text-sm'>
The template you're looking for doesn't exist.
</p>
</div>
</div>
)
@@ -335,8 +258,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'>⚠️ No Workflow Data</div>
<div className='text-sm'>This template doesn't contain workflow state data.</div>
<div className='mb-2 font-medium font-sans text-lg'>⚠️ No Workflow Data</div>
<div className='font-sans text-sm'>
This template doesn't contain workflow state data.
</div>
</div>
</div>
)
@@ -359,8 +284,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'>⚠️ Preview Error</div>
<div className='text-sm'>Unable to render workflow preview</div>
<div className='mb-2 font-medium font-sans text-lg'>⚠️ Preview Error</div>
<div className='font-sans text-sm'>Unable to render workflow preview</div>
</div>
</div>
)
@@ -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<HTMLDivElement>) => {
// 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 (
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
{/* Header */}
<div className='border-b bg-background p-6'>
<div className='mx-auto max-w-7xl'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
>
<ArrowLeft className='h-4 w-4' />
<span className='text-sm'>Back to templates</span>
</button>
{/* Template header */}
<div className='flex items-start justify-between'>
<div className='flex items-start gap-4'>
{/* Icon */}
{/* Title and description */}
<div>
<h1 className='font-bold text-3xl text-foreground'>{template.name}</h1>
{template.details?.tagline && (
<p className='mt-2 max-w-3xl text-lg text-muted-foreground'>
{template.details.tagline}
</p>
)}
{/* Tags */}
{template.tags && template.tags.length > 0 && (
<div className='mt-3 flex flex-wrap gap-2'>
{template.tags.map((tag, index) => (
<Badge
key={index}
variant='secondary'
className='border-0 bg-muted/60 px-2.5 py-0.5 text-sm hover:bg-muted/80'
>
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Top bar with back button and action buttons */}
<div className='flex items-center justify-between'>
{/* Back button */}
<button
onClick={handleBack}
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span>Back</span>
</button>
{/* Action buttons */}
<div className='flex items-center gap-3'>
{/* Super user approve/reject buttons for pending templates */}
<div className='flex items-center gap-[8px]'>
{/* Approve/Reject buttons for super users */}
{isSuperUser && template.status === 'pending' && (
<>
<Button
variant='active'
onClick={handleApprove}
disabled={isApproving}
className='bg-green-600 text-white hover:bg-green-700'
className='h-[32px] rounded-[6px]'
>
{isApproving ? 'Approving...' : 'Approve'}
</Button>
<Button
variant='active'
onClick={handleReject}
disabled={isRejecting}
variant='outline'
className='border-red-600 text-red-600 hover:bg-red-50'
className='h-[32px] rounded-[6px]'
>
{isRejecting ? 'Rejecting...' : 'Reject'}
</Button>
</>
)}
{/* Star button - only for logged-in non-owners and non-pending templates */}
{currentUserId && !canEditTemplate && template.status !== 'pending' && (
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred &&
'border-yellow-500/50 bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-yellow-500')} />
{starCount}
</Button>
)}
{/* Edit button - for template owners */}
{canEditTemplate && currentUserId && (
<>
{(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? (
<Button
variant='active'
onClick={handleEditTemplate}
disabled={isEditing || (!isWorkspaceContext && hasWorkspaceAccess === false)}
className='bg-blue-600 text-white hover:bg-blue-700'
className='h-[32px] rounded-[6px]'
>
{isEditing ? 'Opening...' : 'Edit Template'}
{isEditing ? 'Opening...' : 'Edit'}
</Button>
) : (
<DropdownMenu
@@ -677,15 +576,12 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
>
<DropdownMenuTrigger asChild>
<Button
variant='active'
onClick={() => setShowWorkspaceSelectorForEdit(true)}
disabled={isUsing || isLoadingWorkspaces}
className='bg-blue-600 text-white hover:bg-blue-700'
className='h-[32px] rounded-[6px]'
>
{isUsing
? 'Importing...'
: isLoadingWorkspaces
? 'Loading...'
: 'Edit Template'}
{isUsing ? 'Importing...' : isLoadingWorkspaces ? 'Loading...' : 'Edit'}
<ChevronDown className='ml-2 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
@@ -721,6 +617,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
<>
{!currentUserId ? (
<Button
variant='active'
onClick={() => {
const callbackUrl =
isWorkspaceContext && workspaceId
@@ -730,17 +627,18 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
: encodeURIComponent(`/templates/${template.id}`)
router.push(`/login?callbackUrl=${callbackUrl}`)
}}
className='bg-purple-600 text-white hover:bg-purple-700'
className='h-[32px] rounded-[6px]'
>
Sign in to use
</Button>
) : isWorkspaceContext ? (
<Button
variant='primary'
onClick={handleUseTemplate}
disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700'
className='h-[32px] rounded-[6px]'
>
{isUsing ? 'Creating...' : 'Use this template'}
{isUsing ? 'Creating...' : 'Use'}
</Button>
) : (
<DropdownMenu
@@ -749,15 +647,12 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
>
<DropdownMenuTrigger asChild>
<Button
variant='primary'
onClick={() => setShowWorkspaceSelectorForUse(true)}
disabled={isUsing || isLoadingWorkspaces}
className='bg-purple-600 text-white hover:bg-purple-700'
className='h-[32px] rounded-[6px]'
>
{isUsing
? 'Creating...'
: isLoadingWorkspaces
? 'Loading...'
: 'Use this template'}
{isUsing ? 'Creating...' : isLoadingWorkspaces ? 'Loading...' : 'Use'}
<ChevronDown className='ml-2 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
@@ -790,39 +685,86 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
</div>
</div>
{/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
{/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' />
<span>{template.views} views</span>
</div>
{/* Template name */}
<h1 className='mt-[24px] font-medium text-[18px]'>{template.name}</h1>
{/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' />
<span>{starCount} stars</span>
</div>
{/* Template tagline */}
{template.details?.tagline && (
<p className='mt-[8px] font-medium text-[#888888] text-[14px]'>
{template.details.tagline}
</p>
)}
{/* Author */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<User className='h-3 w-3' />
<span>by {template.creator?.name || 'Unknown'}</span>
</div>
{/* Creator and stats row */}
<div className='mt-[16px] flex items-center gap-[8px]'>
{/* Star icon and count */}
<Star
onClick={handleStarToggle}
className={cn(
'h-[14px] w-[14px] cursor-pointer transition-colors',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarring && 'opacity-50'
)}
/>
<span className='font-medium text-[#888888] text-[14px]'>{starCount}</span>
{/* Author Type - show if organization */}
{template.creator?.referenceType === 'organization' && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Users className='h-3 w-3' />
<span>Organization</span>
{/* Users icon and count */}
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
<span className='font-medium text-[#888888] text-[14px]'>{template.views}</span>
{/* Vertical divider */}
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[#3A3A3A]' />
{/* Creator profile pic */}
{template.creator?.profileImageUrl ? (
<div className='h-[16px] w-[16px] flex-shrink-0 overflow-hidden rounded-full'>
<img
src={template.creator.profileImageUrl}
alt={template.creator.name}
className='h-full w-full object-cover'
/>
</div>
) : (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[14px] w-[14px] text-[#888888]' />
</div>
)}
{/* Creator name */}
<span className='font-medium text-[#8B8B8B] text-[14px]'>
{template.creator?.name || 'Unknown'}
</span>
</div>
{/* Last Updated */}
{/* Credentials needed */}
{Array.isArray(template.requiredCredentials) &&
template.requiredCredentials.length > 0 && (
<p className='mt-[12px] font-medium text-[#888888] text-[12px]'>
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()}`)
return alreadyHasBlock ? cred.label : `${cred.label} for ${blockName}`
})
.join(', ')}
</p>
)}
{/* Canvas preview */}
<div
className='relative mt-[24px] h-[450px] w-full overflow-hidden rounded-[8px] border border-[var(--border)]'
onWheelCapture={handleCanvasWheelCapture}
>
{renderWorkflowPreview()}
{/* Last updated overlay */}
{template.updatedAt && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Clock className='h-3 w-3' />
<span>
<div className='pointer-events-none absolute right-[12px] bottom-[12px] rounded-[4px] bg-[var(--bg)]/80 px-[8px] py-[4px] backdrop-blur-sm'>
<span className='font-medium text-[#8B8B8B] text-[12px]'>
Last updated{' '}
{formatDistanceToNow(new Date(template.updatedAt), {
addSuffix: true,
@@ -831,91 +773,59 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
</div>
)}
</div>
</div>
</div>
{/* Workflow preview */}
<div className='flex-1 p-6'>
<div className='mx-auto max-w-7xl'>
<h2 className='mb-4 font-semibold text-[#0D0D0D] text-lg dark:text-[#F0F0F0]'>
Workflow Preview
</h2>
<div className='h-[600px] w-full'>{renderWorkflowPreview()}</div>
{Array.isArray(template.requiredCredentials) &&
template.requiredCredentials.length > 0 && (
<div className='mt-8 border-t pt-8'>
<h3 className='mb-4 font-semibold text-[#0D0D0D] text-base dark:text-[#F0F0F0]'>
Credentials Needed
</h3>
<ul className='ml-5 list-disc space-y-1.5 text-[#707070] text-sm leading-[1.4rem] dark:text-[#E8E8E8]'>
{template.requiredCredentials.map((cred: CredentialRequirement, idx: number) => {
// Get block name from registry or format blockType
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 <li key={idx}>{text}</li>
})}
</ul>
</div>
)}
{/* About this Workflow */}
{template.details?.about && (
<div className='mt-8 border-t pt-8'>
<h3 className='mb-4 font-semibold text-[#0D0D0D] text-base dark:text-[#F0F0F0]'>
<div className='mt-8'>
<h3 className='mb-4 font-sans font-semibold text-base text-foreground'>
About this Workflow
</h3>
<div className='max-w-none space-y-2'>
<ReactMarkdown
components={{
p: ({ children }) => (
<p className='mb-2 text-[#707070] text-sm leading-[1.4rem] last:mb-0 dark:text-[#E8E8E8]'>
<p className='mb-2 font-sans text-muted-foreground text-sm leading-[1.4rem] last:mb-0'>
{children}
</p>
),
h1: ({ children }) => (
<h1 className='mt-6 mb-3 font-semibold text-[#0D0D0D] text-xl first:mt-0 dark:text-[#F0F0F0]'>
<h1 className='mt-6 mb-3 font-sans font-semibold text-foreground text-xl first:mt-0'>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className='mt-5 mb-2.5 font-semibold text-[#0D0D0D] text-lg first:mt-0 dark:text-[#F0F0F0]'>
<h2 className='mt-5 mb-2.5 font-sans font-semibold text-foreground text-lg first:mt-0'>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className='mt-4 mb-2 font-semibold text-[#0D0D0D] text-base first:mt-0 dark:text-[#F0F0F0]'>
<h3 className='mt-4 mb-2 font-sans font-semibold text-base text-foreground first:mt-0'>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className='mt-3 mb-2 font-semibold text-[#0D0D0D] text-sm first:mt-0 dark:text-[#F0F0F0]'>
<h4 className='mt-3 mb-2 font-sans font-semibold text-foreground text-sm first:mt-0'>
{children}
</h4>
),
ul: ({ children }) => (
<ul className='my-2 ml-5 list-disc space-y-1.5 text-[#707070] text-sm dark:text-[#E8E8E8]'>
<ul className='my-2 ml-5 list-disc space-y-1.5 font-sans text-muted-foreground text-sm'>
{children}
</ul>
),
ol: ({ children }) => (
<ol className='my-2 ml-5 list-decimal space-y-1.5 text-[#707070] text-sm dark:text-[#E8E8E8]'>
<ol className='my-2 ml-5 list-decimal space-y-1.5 font-sans text-muted-foreground text-sm'>
{children}
</ol>
),
li: ({ children }) => <li className='leading-[1.4rem]'>{children}</li>,
code: ({ inline, children }: any) =>
inline ? (
<code className='rounded bg-[#F5F5F5] px-1.5 py-0.5 font-mono text-[#F59E0B] text-xs dark:bg-[#2A2A2A]'>
<code className='rounded bg-muted px-1.5 py-0.5 font-mono text-[#F59E0B] text-xs'>
{children}
</code>
) : (
<code className='my-2 block overflow-x-auto rounded-md bg-[#F5F5F5] p-3 font-mono text-[#0D0D0D] text-xs dark:bg-[#1A1A1A] dark:text-[#E5E5E5]'>
<code className='my-2 block overflow-x-auto rounded-md bg-muted p-3 font-mono text-foreground text-xs'>
{children}
</code>
),
@@ -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}
</a>
),
strong: ({ children }) => (
<strong className='font-semibold text-[#0D0D0D] dark:text-white'>
<strong className='font-sans font-semibold text-foreground'>
{children}
</strong>
),
em: ({ children }) => (
<em className='text-[#888888] dark:text-[#B8B8B8]'>{children}</em>
),
em: ({ children }) => <em className='text-muted-foreground'>{children}</em>,
}}
>
{template.details.about}
@@ -945,17 +853,21 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
</div>
)}
{/* Creator Profile */}
{template.creator && (
<div className='mt-8 border-t pt-8'>
<h3 className='mb-4 font-semibold text-[#0D0D0D] text-base dark:text-[#F0F0F0]'>
About the Creator
</h3>
<div className='flex items-start gap-4'>
{/* Profile Picture */}
<div className='flex-shrink-0'>
{/* 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) && (
<div className='mt-8'>
<h3 className='mb-4 font-sans font-semibold text-base text-foreground'>
About the Creator
</h3>
<div className='flex items-start gap-4'>
{/* Creator profile image */}
{template.creator.profileImageUrl ? (
<div className='relative h-16 w-16 overflow-hidden rounded-full ring-1 ring-border'>
<div className='h-[48px] w-[48px] flex-shrink-0 overflow-hidden rounded-full'>
<img
src={template.creator.profileImageUrl}
alt={template.creator.name}
@@ -963,77 +875,106 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
/>
</div>
) : (
<div className='flex h-16 w-16 items-center justify-center rounded-full bg-purple-600 ring-1 ring-border'>
<User className='h-8 w-8 text-white' />
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[24px] w-[24px] text-[#888888]' />
</div>
)}
</div>
{/* Creator Info */}
<div className='min-w-0 flex-1'>
<h4 className='font-semibold text-[#0D0D0D] dark:text-[#F0F0F0]'>
{template.creator.name}
</h4>
{template.creator.details?.about && (
<p className='mt-1.5 text-[#707070] text-sm leading-[1.4rem] dark:text-[#E8E8E8]'>
{template.creator.details.about}
</p>
)}
{/* Creator details */}
<div className='flex-1'>
<div className='mb-[5px] flex items-center gap-3'>
<h4 className='font-sans font-semibold text-base text-foreground'>
{template.creator.name}
</h4>
{/* Social Links */}
{(template.creator.details?.xUrl ||
template.creator.details?.linkedinUrl ||
template.creator.details?.websiteUrl ||
template.creator.details?.contactEmail) && (
<div className='mt-3 flex flex-wrap gap-4'>
{template.creator.details.xUrl && (
<a
href={template.creator.details.xUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[#707070] text-sm transition-colors hover:text-[#0D0D0D] dark:text-[#E8E8E8] dark:hover:text-[#F0F0F0]'
>
<Twitter className='h-3.5 w-3.5' />
<span>X</span>
</a>
)}
{template.creator.details.linkedinUrl && (
<a
href={template.creator.details.linkedinUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[#707070] text-sm transition-colors hover:text-[#0D0D0D] dark:text-[#E8E8E8] dark:hover:text-[#F0F0F0]'
>
<Linkedin className='h-3.5 w-3.5' />
<span>LinkedIn</span>
</a>
)}
{template.creator.details.websiteUrl && (
<a
href={template.creator.details.websiteUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[#707070] text-sm transition-colors hover:text-[#0D0D0D] dark:text-[#E8E8E8] dark:hover:text-[#F0F0F0]'
>
<Globe className='h-3.5 w-3.5' />
<span>Website</span>
</a>
)}
{template.creator.details.contactEmail && (
<a
href={`mailto:${template.creator.details.contactEmail}`}
className='inline-flex items-center gap-1.5 text-[#707070] text-sm transition-colors hover:text-[#0D0D0D] dark:text-[#E8E8E8] dark:hover:text-[#F0F0F0]'
>
<Mail className='h-3.5 w-3.5' />
<span>Contact</span>
</a>
)}
{/* Social links */}
<div className='flex items-center gap-[12px]'>
{template.creator.details?.websiteUrl && (
<a
href={template.creator.details.websiteUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
aria-label='Website'
>
<Globe className='h-[14px] w-[14px]' />
</a>
)}
{template.creator.details?.xUrl && (
<a
href={template.creator.details.xUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
aria-label='X (Twitter)'
>
<svg
className='h-[14px] w-[14px]'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z' />
</svg>
</a>
)}
{template.creator.details?.linkedinUrl && (
<a
href={template.creator.details.linkedinUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
aria-label='LinkedIn'
>
<Linkedin className='h-[14px] w-[14px]' />
</a>
)}
{template.creator.details?.contactEmail && (
<a
href={`mailto:${template.creator.details.contactEmail}`}
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
aria-label='Email'
>
<Mail className='h-[14px] w-[14px]' />
</a>
)}
</div>
</div>
)}
{/* Creator bio */}
{template.creator.details?.about && (
<div className='max-w-none'>
<ReactMarkdown
components={{
p: ({ children }) => (
<p className='mb-2 font-sans text-muted-foreground text-sm leading-[1.4rem] last:mb-0'>
{children}
</p>
),
a: ({ href, children }) => (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
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}
</a>
),
strong: ({ children }) => (
<strong className='font-sans font-semibold text-foreground'>
{children}
</strong>
),
}}
>
{template.creator.details.about}
</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
@@ -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 */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
isStarLoading && 'opacity-50'
{/* Super user approval buttons for pending templates */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
isStarLoading && 'opacity-50'
)}
/>
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</div>
</div>
@@ -387,10 +533,16 @@ export function TemplateCard({
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
{authorImageUrl ? (
<div className='h-3 w-3 flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<User className='h-3 w-3 flex-shrink-0' />
)}
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'>•</span>
<User className='h-3 w-3 flex-shrink-0' />
<ChartNoAxesColumn className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
@@ -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 (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}
-564
View File
@@ -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<string, { type: string; name?: string }>
}
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 (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
</div>
</div>
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): 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 (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Approve/Reject buttons for pending templates (super users only) */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
isStarLoading && 'opacity-50'
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
</div>
</div>
{/* Description */}
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'>•</span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span>•</span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{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 (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})
)}
</div>
</div>
)
}
+1
View File
@@ -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}
@@ -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<HTMLDivElement | null>(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 (
<div
@@ -263,10 +299,13 @@ export function TemplateCard({
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div className='h-[180px] w-full overflow-hidden rounded-[6px]'>
{normalizeWorkflowState(state) ? (
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
workflowState={normalizeWorkflowState(state)!}
workflowState={normalizedState}
showSubBlocks={false}
height={180}
width='100%'
@@ -341,7 +380,15 @@ export function TemplateCard({
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] flex-shrink-0 rounded-full bg-[#4A4A4A]' />
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[18px] w-[18px] text-[#888888]' />
</div>
)}
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
@@ -363,3 +410,5 @@ export function TemplateCard({
</div>
)
}
export const TemplateCard = memo(TemplateCardInner)
@@ -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 (
<div className='flex h-[100vh] flex-col pl-64'>
@@ -158,12 +157,12 @@ export default function Templates({
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[var(--brand-warning-border)] bg-[var(--brand-warning-bg)]'>
<Layout className='h-[14px] w-[14px] text-[var(--brand-warning)]' />
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
</div>
<h1 className='font-medium text-[18px]'>Templates</h1>
</div>
<p className='mt-[10px] font-base text-[14px] text-[var(--text-tertiary)]'>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
Grab a template and start building, or make one from scratch.
</p>
</div>
@@ -213,19 +212,16 @@ export default function Templates({
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
) : filteredTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-[var(--border)] border-dashed bg-[var(--surface-3)]'>
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{emptyState.title}
</p>
<p className='mt-1 text-[var(--text-tertiary)] text-xs'>
{emptyState.description}
</p>
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
</div>
</div>
) : (
filteredTemplates.map((template) => {
const author = template.author || template.creator?.name || 'Unknown'
const authorImageUrl = template.creator?.profileImageUrl || null
return (
<TemplateCard
@@ -234,6 +230,7 @@ export default function Templates({
title={template.name}
description={template.description || template.details?.tagline || ''}
author={author}
authorImageUrl={authorImageUrl}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}