mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement: templates styling (#1952)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user