mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand * feat: EMCN breadcrumb; improvement(KB): UI * fix: hydration error * improvement(KB): UI * feat: emcn modal sizing, KB tags; refactor: deleted old sidebar * feat(logs): UI * fix: add documents modal name * feat: logs, emcn, cursorrules; refactor: logs * feat: dashboard * feat: notifications; improvement: logs details * fixed random rectangle on canvas * fixed the name of the file to align * fix build --------- Co-authored-by: waleed <walif6@gmail.com>
1016 lines
38 KiB
TypeScript
1016 lines
38 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { formatDistanceToNow } from 'date-fns'
|
||
import {
|
||
ChartNoAxesColumn,
|
||
ChevronDown,
|
||
Globe,
|
||
Linkedin,
|
||
Mail,
|
||
Share2,
|
||
Star,
|
||
User,
|
||
} from 'lucide-react'
|
||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||
import ReactMarkdown from 'react-markdown'
|
||
import {
|
||
Breadcrumb,
|
||
Button,
|
||
Copy,
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverItem,
|
||
PopoverTrigger,
|
||
} from '@/components/emcn'
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu'
|
||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||
import { useSession } from '@/lib/auth/auth-client'
|
||
import { cn } from '@/lib/core/utils/cn'
|
||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||
import { createLogger } from '@/lib/logs/console/logger'
|
||
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||
import { getBlock } from '@/blocks/registry'
|
||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||
|
||
const logger = createLogger('TemplateDetails')
|
||
|
||
interface TemplateDetailsProps {
|
||
isWorkspaceContext?: boolean
|
||
}
|
||
|
||
export default function TemplateDetails({ isWorkspaceContext = false }: TemplateDetailsProps) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const params = useParams()
|
||
const templateId = params?.id as string
|
||
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
|
||
const { data: session } = useSession()
|
||
|
||
const { data: template, isLoading: loading } = useTemplate(templateId)
|
||
const starTemplate = useStarTemplate()
|
||
|
||
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
|
||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||
Array<{ organizationId: string; role: string }>
|
||
>([])
|
||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||
const [isUsing, setIsUsing] = useState(false)
|
||
const [isEditing, setIsEditing] = useState(false)
|
||
const [isApproving, setIsApproving] = useState(false)
|
||
const [isRejecting, setIsRejecting] = useState(false)
|
||
const [isVerifying, setIsVerifying] = useState(false)
|
||
const [hasWorkspaceAccess, setHasWorkspaceAccess] = useState<boolean | null>(null)
|
||
const [workspaces, setWorkspaces] = useState<
|
||
Array<{ id: string; name: string; permissions: string }>
|
||
>([])
|
||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
|
||
const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
|
||
|
||
const currentUserId = session?.user?.id || null
|
||
|
||
useEffect(() => {
|
||
const fetchUserOrganizations = async () => {
|
||
if (!currentUserId) return
|
||
|
||
try {
|
||
const response = await fetch('/api/organizations')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
const orgs = data.organizations || []
|
||
const orgIds = orgs.map((org: any) => org.id)
|
||
const orgRoles = orgs.map((org: any) => ({
|
||
organizationId: org.id,
|
||
role: org.role,
|
||
}))
|
||
setCurrentUserOrgs(orgIds)
|
||
setCurrentUserOrgRoles(orgRoles)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error fetching organizations:', error)
|
||
}
|
||
}
|
||
|
||
const fetchSuperUserStatus = async () => {
|
||
if (!currentUserId) return
|
||
|
||
try {
|
||
const response = await fetch('/api/user/super-user')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setIsSuperUser(data.isSuperUser || false)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error fetching super user status:', error)
|
||
}
|
||
}
|
||
|
||
fetchSuperUserStatus()
|
||
fetchUserOrganizations()
|
||
}, [currentUserId])
|
||
|
||
useEffect(() => {
|
||
if (!currentUserId) return
|
||
|
||
const fetchWorkspaces = async () => {
|
||
try {
|
||
setIsLoadingWorkspaces(true)
|
||
const response = await fetch('/api/workspaces')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
const availableWorkspaces = data.workspaces
|
||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||
.map((ws: any) => ({
|
||
id: ws.id,
|
||
name: ws.name,
|
||
permissions: ws.permissions,
|
||
}))
|
||
setWorkspaces(availableWorkspaces)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error fetching workspaces:', error)
|
||
} finally {
|
||
setIsLoadingWorkspaces(false)
|
||
}
|
||
}
|
||
|
||
fetchWorkspaces()
|
||
}, [currentUserId])
|
||
|
||
useEffect(() => {
|
||
if (template && searchParams?.get('use') === 'true' && currentUserId) {
|
||
if (isWorkspaceContext && workspaceId) {
|
||
handleWorkspaceSelectForUse(workspaceId)
|
||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||
} else {
|
||
router.replace(`/templates/${template.id}`)
|
||
}
|
||
}
|
||
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
|
||
|
||
const canEditTemplate = (() => {
|
||
if (!currentUserId || !template?.creator) return false
|
||
|
||
if (template.creator.referenceType === 'user') {
|
||
return template.creator.referenceId === currentUserId
|
||
}
|
||
|
||
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
|
||
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
|
||
|
||
if (template.workflowId) {
|
||
return isOrgMember
|
||
}
|
||
|
||
const orgMembership = currentUserOrgRoles.find(
|
||
(org) => org.organizationId === template.creator?.referenceId
|
||
)
|
||
const isAdminOrOwner = orgMembership?.role === 'admin' || orgMembership?.role === 'owner'
|
||
|
||
return isOrgMember && isAdminOrOwner
|
||
}
|
||
|
||
return false
|
||
})()
|
||
|
||
useEffect(() => {
|
||
const checkWorkspaceAccess = async () => {
|
||
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
|
||
setHasWorkspaceAccess(null)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||
if (checkResponse.status === 403) {
|
||
setHasWorkspaceAccess(false)
|
||
} else if (checkResponse.ok) {
|
||
setHasWorkspaceAccess(true)
|
||
} else {
|
||
setHasWorkspaceAccess(null)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error checking workspace access:', error)
|
||
setHasWorkspaceAccess(null)
|
||
}
|
||
}
|
||
|
||
checkWorkspaceAccess()
|
||
}, [template?.workflowId, currentUserId, canEditTemplate])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className='flex h-screen items-center justify-center'>
|
||
<div className='text-center'>
|
||
<p className='font-sans text-muted-foreground text-sm'>Loading template...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!template) {
|
||
return (
|
||
<div className='flex h-screen items-center justify-center'>
|
||
<div className='text-center'>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
const renderWorkflowPreview = () => {
|
||
if (!template.state) {
|
||
return (
|
||
<div className='flex h-full items-center justify-center text-center'>
|
||
<div className='text-muted-foreground'>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
try {
|
||
return (
|
||
<WorkflowPreview
|
||
workflowState={template.state}
|
||
showSubBlocks={true}
|
||
height='100%'
|
||
width='100%'
|
||
isPannable={true}
|
||
defaultPosition={{ x: 0, y: 0 }}
|
||
defaultZoom={1}
|
||
/>
|
||
)
|
||
} catch (error) {
|
||
logger.error('Error rendering workflow preview:', error)
|
||
return (
|
||
<div className='flex h-full items-center justify-center text-center'>
|
||
<div className='text-muted-foreground'>
|
||
<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>
|
||
)
|
||
}
|
||
}
|
||
|
||
const breadcrumbItems = [
|
||
{
|
||
label: 'Templates',
|
||
href: isWorkspaceContext ? `/workspace/${workspaceId}/templates` : '/templates',
|
||
},
|
||
{ label: template?.name || 'Template' },
|
||
]
|
||
/**
|
||
* 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>) => {
|
||
if (event.ctrlKey || event.metaKey) {
|
||
return
|
||
}
|
||
|
||
event.stopPropagation()
|
||
}
|
||
|
||
const handleStarToggle = async () => {
|
||
if (!currentUserId || !template) return
|
||
|
||
starTemplate.mutate({
|
||
templateId: template.id,
|
||
action: template.isStarred ? 'remove' : 'add',
|
||
})
|
||
}
|
||
|
||
const handleUseTemplate = () => {
|
||
if (!currentUserId) {
|
||
const callbackUrl =
|
||
isWorkspaceContext && workspaceId
|
||
? encodeURIComponent(`/workspace/${workspaceId}/templates/${template.id}?use=true`)
|
||
: encodeURIComponent(`/templates/${template.id}`)
|
||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||
return
|
||
}
|
||
|
||
if (isWorkspaceContext && workspaceId) {
|
||
handleWorkspaceSelectForUse(workspaceId)
|
||
}
|
||
}
|
||
|
||
const handleEditTemplate = async () => {
|
||
if (!currentUserId || !template) return
|
||
|
||
if (isWorkspaceContext && workspaceId && template.workflowId) {
|
||
setIsEditing(true)
|
||
try {
|
||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||
|
||
if (checkResponse.ok) {
|
||
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
|
||
return
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error checking workflow:', error)
|
||
} finally {
|
||
setIsEditing(false)
|
||
}
|
||
}
|
||
|
||
if (template.workflowId && !isWorkspaceContext) {
|
||
setIsEditing(true)
|
||
try {
|
||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||
|
||
if (checkResponse.status === 403) {
|
||
alert("You don't have access to the workspace containing this template")
|
||
return
|
||
}
|
||
|
||
if (checkResponse.ok) {
|
||
const result = await checkResponse.json()
|
||
const templateWorkspaceId = result.data?.workspaceId
|
||
if (templateWorkspaceId) {
|
||
window.location.href = `/workspace/${templateWorkspaceId}/w/${template.workflowId}`
|
||
return
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error checking workflow:', error)
|
||
} finally {
|
||
setIsEditing(false)
|
||
}
|
||
}
|
||
|
||
if (isWorkspaceContext && workspaceId) {
|
||
handleWorkspaceSelectForEdit(workspaceId)
|
||
} else {
|
||
setShowWorkspaceSelectorForEdit(true)
|
||
}
|
||
}
|
||
|
||
const handleWorkspaceSelectForUse = async (workspaceId: string) => {
|
||
if (isUsing || !template) return
|
||
|
||
setIsUsing(true)
|
||
try {
|
||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workspaceId }),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to use template')
|
||
}
|
||
|
||
const { workflowId } = await response.json()
|
||
|
||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||
} catch (error) {
|
||
logger.error('Error using template:', error)
|
||
} finally {
|
||
setIsUsing(false)
|
||
}
|
||
}
|
||
|
||
const handleWorkspaceSelectForEdit = async (workspaceId: string) => {
|
||
if (isUsing || !template) return
|
||
|
||
setIsUsing(true)
|
||
setShowWorkspaceSelectorForEdit(false)
|
||
try {
|
||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workspaceId, connectToTemplate: true }),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to import template for editing')
|
||
}
|
||
|
||
const { workflowId } = await response.json()
|
||
|
||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||
} catch (error) {
|
||
logger.error('Error importing template for editing:', error)
|
||
} finally {
|
||
setIsUsing(false)
|
||
}
|
||
}
|
||
|
||
const handleApprove = async () => {
|
||
if (isApproving || !template) return
|
||
|
||
setIsApproving(true)
|
||
try {
|
||
const response = await fetch(`/api/templates/${template.id}/approve`, {
|
||
method: 'POST',
|
||
})
|
||
|
||
if (response.ok) {
|
||
if (isWorkspaceContext && workspaceId) {
|
||
router.push(`/workspace/${workspaceId}/templates`)
|
||
} else {
|
||
router.push('/templates')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error approving template:', error)
|
||
} finally {
|
||
setIsApproving(false)
|
||
}
|
||
}
|
||
|
||
const handleReject = async () => {
|
||
if (isRejecting || !template) return
|
||
|
||
setIsRejecting(true)
|
||
try {
|
||
const response = await fetch(`/api/templates/${template.id}/reject`, {
|
||
method: 'POST',
|
||
})
|
||
|
||
if (response.ok) {
|
||
if (isWorkspaceContext && workspaceId) {
|
||
router.push(`/workspace/${workspaceId}/templates`)
|
||
} else {
|
||
router.push('/templates')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error rejecting template:', error)
|
||
} finally {
|
||
setIsRejecting(false)
|
||
}
|
||
}
|
||
|
||
const handleToggleVerification = async () => {
|
||
if (isVerifying || !template?.creator?.id) return
|
||
|
||
setIsVerifying(true)
|
||
try {
|
||
const endpoint = `/api/creators/${template.creator.id}/verify`
|
||
const method = template.creator.verified ? 'DELETE' : 'POST'
|
||
|
||
const response = await fetch(endpoint, { method })
|
||
|
||
if (response.ok) {
|
||
// Refresh page to show updated verification status
|
||
window.location.reload()
|
||
} else {
|
||
const error = await response.json()
|
||
logger.error('Error toggling verification:', error)
|
||
alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error toggling verification:', error)
|
||
alert('An error occurred while toggling verification')
|
||
} finally {
|
||
setIsVerifying(false)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shares the template to X (Twitter)
|
||
*/
|
||
const handleShareToTwitter = () => {
|
||
if (!template) return
|
||
|
||
setSharePopoverOpen(false)
|
||
const templateUrl = `${getBaseUrl()}/templates/${template.id}`
|
||
|
||
let tweetText = `🚀 Check out this workflow template: ${template.name}`
|
||
|
||
if (template.details?.tagline) {
|
||
const taglinePreview =
|
||
template.details.tagline.length > 100
|
||
? `${template.details.tagline.substring(0, 100)}...`
|
||
: template.details.tagline
|
||
tweetText += `\n\n${taglinePreview}`
|
||
}
|
||
|
||
const maxTextLength = 280 - 23 - 1
|
||
if (tweetText.length > maxTextLength) {
|
||
tweetText = `${tweetText.substring(0, maxTextLength - 3)}...`
|
||
}
|
||
|
||
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(templateUrl)}`
|
||
window.open(twitterUrl, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
/**
|
||
* Shares the template to LinkedIn.
|
||
*/
|
||
const handleShareToLinkedIn = () => {
|
||
if (!template) return
|
||
|
||
setSharePopoverOpen(false)
|
||
const templateUrl = `${getBaseUrl()}/templates/${template.id}`
|
||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(templateUrl)}`
|
||
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
const handleCopyLink = async () => {
|
||
setSharePopoverOpen(false)
|
||
const templateUrl = `${getBaseUrl()}/templates/${template?.id}`
|
||
try {
|
||
await navigator.clipboard.writeText(templateUrl)
|
||
logger.info('Template link copied to clipboard')
|
||
} catch (error) {
|
||
logger.error('Failed to copy link:', error)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={cn('flex flex-col', isWorkspaceContext ? 'h-full flex-1' : 'min-h-screen')}>
|
||
<div className='flex flex-1 overflow-hidden'>
|
||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||
{/* Breadcrumb navigation */}
|
||
<Breadcrumb items={breadcrumbItems} />
|
||
|
||
{/* Template name and action buttons */}
|
||
<div className='mt-[14px] flex items-center justify-between'>
|
||
<h1 className='font-medium text-[18px] text-[var(--text-primary)]'>{template.name}</h1>
|
||
|
||
{/* Action buttons */}
|
||
<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='h-[32px] rounded-[6px]'
|
||
>
|
||
{isApproving ? 'Approving...' : 'Approve'}
|
||
</Button>
|
||
<Button
|
||
variant='active'
|
||
onClick={handleReject}
|
||
disabled={isRejecting}
|
||
className='h-[32px] rounded-[6px]'
|
||
>
|
||
{isRejecting ? 'Rejecting...' : 'Reject'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{/* Edit button - for template owners */}
|
||
{canEditTemplate && currentUserId && (
|
||
<>
|
||
{(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? (
|
||
<Button
|
||
variant='active'
|
||
onClick={handleEditTemplate}
|
||
disabled={isEditing || (!isWorkspaceContext && hasWorkspaceAccess === false)}
|
||
className='h-[32px] rounded-[6px]'
|
||
>
|
||
{isEditing ? 'Opening...' : 'Edit'}
|
||
</Button>
|
||
) : (
|
||
<DropdownMenu
|
||
open={showWorkspaceSelectorForEdit}
|
||
onOpenChange={setShowWorkspaceSelectorForEdit}
|
||
>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant='active'
|
||
onClick={() => setShowWorkspaceSelectorForEdit(true)}
|
||
disabled={isUsing || isLoadingWorkspaces}
|
||
className='h-[32px] rounded-[6px]'
|
||
>
|
||
{isUsing ? 'Importing...' : isLoadingWorkspaces ? 'Loading...' : 'Edit'}
|
||
<ChevronDown className='ml-2 h-4 w-4' />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align='end' className='w-56'>
|
||
{workspaces.length === 0 ? (
|
||
<DropdownMenuItem disabled className='text-muted-foreground text-sm'>
|
||
No workspaces with write access
|
||
</DropdownMenuItem>
|
||
) : (
|
||
workspaces.map((workspace) => (
|
||
<DropdownMenuItem
|
||
key={workspace.id}
|
||
onClick={() => handleWorkspaceSelectForEdit(workspace.id)}
|
||
className='flex cursor-pointer items-center justify-between'
|
||
>
|
||
<div className='flex flex-col'>
|
||
<span className='font-medium text-sm'>{workspace.name}</span>
|
||
<span className='text-muted-foreground text-xs capitalize'>
|
||
{workspace.permissions} access
|
||
</span>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
))
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Use template button - only for approved templates and non-owners */}
|
||
{template.status === 'approved' && !canEditTemplate && (
|
||
<>
|
||
{!currentUserId ? (
|
||
<Button
|
||
variant='primary'
|
||
onClick={() => {
|
||
const callbackUrl =
|
||
isWorkspaceContext && workspaceId
|
||
? encodeURIComponent(
|
||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||
)
|
||
: encodeURIComponent(`/templates/${template.id}`)
|
||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||
}}
|
||
className='h-[32px] rounded-[6px]'
|
||
>
|
||
Sign in to use
|
||
</Button>
|
||
) : isWorkspaceContext ? (
|
||
<Button
|
||
variant='primary'
|
||
onClick={handleUseTemplate}
|
||
disabled={isUsing}
|
||
className='!text-[#FFFFFF] h-[32px] rounded-[6px] px-[12px] text-[14px]'
|
||
>
|
||
{isUsing ? 'Creating...' : 'Use template'}
|
||
</Button>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
{/* Share button */}
|
||
<Popover open={sharePopoverOpen} onOpenChange={setSharePopoverOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button variant='active' className='h-[32px] rounded-[6px] px-[12px]'>
|
||
<Share2 className='h-[14px] w-[14px]' />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align='end' side='bottom' sideOffset={8}>
|
||
<PopoverItem onClick={handleCopyLink}>
|
||
<Copy className='h-3 w-3' />
|
||
<span>Copy link</span>
|
||
</PopoverItem>
|
||
<PopoverItem onClick={handleShareToTwitter}>
|
||
<svg
|
||
className='h-3 w-3'
|
||
viewBox='0 0 24 24'
|
||
fill='currentColor'
|
||
xmlns='http://www.w3.org/2000/svg'
|
||
>
|
||
<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>
|
||
<span>Share on X</span>
|
||
</PopoverItem>
|
||
<PopoverItem onClick={handleShareToLinkedIn}>
|
||
<Linkedin className='h-3 w-3' />
|
||
<span>Share on LinkedIn</span>
|
||
</PopoverItem>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Template tagline */}
|
||
{template.details?.tagline && (
|
||
<p className='mt-[4px] font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||
{template.details.tagline}
|
||
</p>
|
||
)}
|
||
|
||
{/* 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',
|
||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[var(--text-muted)]',
|
||
starTemplate.isPending && 'opacity-50'
|
||
)}
|
||
/>
|
||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||
{template.stars || 0}
|
||
</span>
|
||
|
||
{/* Users icon and count */}
|
||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[var(--text-muted)]' />
|
||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||
{template.views}
|
||
</span>
|
||
|
||
{/* Vertical divider */}
|
||
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[var(--border)]' />
|
||
|
||
{/* 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-[var(--surface-elevated)]'>
|
||
<User className='h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||
</div>
|
||
)}
|
||
{/* Creator name */}
|
||
<div className='flex items-center gap-[4px]'>
|
||
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||
{template.creator?.name || 'Unknown'}
|
||
</span>
|
||
{template.creator?.verified && <VerifiedBadge size='md' />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Credentials needed */}
|
||
{Array.isArray(template.requiredCredentials) &&
|
||
template.requiredCredentials.length > 0 && (
|
||
<p className='mt-[12px] font-medium text-[12px] text-[var(--text-muted)]'>
|
||
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='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-[12px] text-[var(--text-muted)]'>
|
||
Last updated{' '}
|
||
{formatDistanceToNow(new Date(template.updatedAt), {
|
||
addSuffix: true,
|
||
})}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* About this Workflow */}
|
||
{template.details?.about && (
|
||
<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 font-sans text-muted-foreground text-sm leading-[1.4rem] last:mb-0'>
|
||
{children}
|
||
</p>
|
||
),
|
||
h1: ({ children }) => (
|
||
<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-sans font-semibold text-foreground text-lg first:mt-0'>
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<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-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 font-sans text-muted-foreground text-sm'>
|
||
{children}
|
||
</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<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-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-muted p-3 font-mono text-foreground text-xs'>
|
||
{children}
|
||
</code>
|
||
),
|
||
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>
|
||
),
|
||
em: ({ children }) => <em className='text-muted-foreground'>{children}</em>,
|
||
}}
|
||
>
|
||
{template.details.about}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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'>
|
||
<div className='mb-4 flex items-center justify-between'>
|
||
<h3 className='font-sans font-semibold text-base text-foreground'>
|
||
About the Creator
|
||
</h3>
|
||
{isSuperUser && template.creator && (
|
||
<Button
|
||
variant={template.creator.verified ? 'active' : 'default'}
|
||
onClick={handleToggleVerification}
|
||
disabled={isVerifying}
|
||
className='h-[28px] rounded-[6px] text-[12px]'
|
||
>
|
||
{isVerifying
|
||
? 'Updating...'
|
||
: template.creator.verified
|
||
? 'Unverify Creator'
|
||
: 'Verify Creator'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className='flex items-start gap-4'>
|
||
{/* Creator profile image */}
|
||
{template.creator.profileImageUrl ? (
|
||
<div className='h-[48px] w-[48px] 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-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
|
||
<User className='h-[24px] w-[24px] text-[var(--text-muted)]' />
|
||
</div>
|
||
)}
|
||
|
||
{/* Creator details */}
|
||
<div className='flex-1'>
|
||
<div className='mb-[5px] flex items-center gap-3'>
|
||
<div className='flex items-center gap-[6px]'>
|
||
<h4 className='font-sans font-semibold text-base text-foreground'>
|
||
{template.creator.name}
|
||
</h4>
|
||
{template.creator.verified && <VerifiedBadge size='md' />}
|
||
</div>
|
||
|
||
{/* 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-[var(--text-muted)] 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-[var(--text-muted)] 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-[var(--text-muted)] 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-[var(--text-muted)] 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>
|
||
)
|
||
}
|