mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(templates): fix template details page (#1940)
* Fix v1 * Template details page
This commit is contained in:
@@ -1,105 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templateStars, templates } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
|
||||
|
||||
const logger = createLogger('TemplatePage')
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
|
||||
try {
|
||||
if (!id || typeof id !== 'string' || id.length !== 36) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
const templateData = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (templateData.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { template, creator } = templateData[0]
|
||||
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (!template.id || !template.name) {
|
||||
logger.error('Template missing required fields:', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
})
|
||||
notFound()
|
||||
}
|
||||
|
||||
let isStarred = false
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
const starData = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(
|
||||
and(
|
||||
eq(templateStars.templateId, template.id),
|
||||
eq(templateStars.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
isStarred = starData.length > 0
|
||||
} catch {
|
||||
isStarred = false
|
||||
}
|
||||
}
|
||||
|
||||
const serializedTemplate = {
|
||||
...template,
|
||||
creator: creator || null,
|
||||
createdAt: template.createdAt.toISOString(),
|
||||
updatedAt: template.updatedAt.toISOString(),
|
||||
isStarred,
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateDetails
|
||||
template={JSON.parse(JSON.stringify(serializedTemplate))}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={session?.user?.id || null}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error loading template:', error)
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-[14px] font-medium text-[18px]'>Error Loading Template</h1>
|
||||
<p className='text-[#888888] text-[14px]'>There was an error loading this template.</p>
|
||||
<p className='mt-[10px] text-[#888888] text-[12px]'>Template ID: {id}</p>
|
||||
<p className='mt-[10px] text-[12px] text-red-500'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Workspace-scoped template detail page.
|
||||
* Data fetching is handled client-side in the TemplateDetails component.
|
||||
*/
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Linkedin,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
@@ -39,28 +40,26 @@ import {
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Twitter,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
|
||||
interface TemplateDetailsProps {
|
||||
template: Template
|
||||
workspaceId: string
|
||||
currentUserId: string | null
|
||||
}
|
||||
|
||||
// Icon mapping - reuse from template-card
|
||||
const iconMap = {
|
||||
FileText,
|
||||
@@ -115,22 +114,69 @@ const getIconComponent = (icon: string): React.ReactNode => {
|
||||
)
|
||||
}
|
||||
|
||||
export default function TemplateDetails({
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
}: TemplateDetailsProps) {
|
||||
/**
|
||||
* Template detail page component
|
||||
* Fetches and displays detailed information about a specific template
|
||||
*/
|
||||
export default function TemplateDetails() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const params = useParams()
|
||||
|
||||
// Initialize all state hooks first (hooks must be called unconditionally)
|
||||
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
|
||||
const [starCount, setStarCount] = useState(template?.stars || 0)
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const templateId = params?.id as string
|
||||
|
||||
// State for template data
|
||||
const [template, setTemplate] = useState<Template | null>(null)
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isStarred, setIsStarred] = useState(false)
|
||||
const [starCount, setStarCount] = useState(0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const isOwner = currentUserId && template?.userId === currentUserId
|
||||
// Fetch template data on client side
|
||||
useEffect(() => {
|
||||
if (!templateId || !workspaceId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchTemplate = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setTemplate(data.data)
|
||||
setIsStarred(data.data.isStarred || false)
|
||||
setStarCount(data.data.stars || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/get-session')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCurrentUserId(data?.user?.id || null)
|
||||
} else {
|
||||
setCurrentUserId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching session:', error)
|
||||
setCurrentUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTemplate()
|
||||
fetchCurrentUser()
|
||||
}, [templateId, workspaceId])
|
||||
|
||||
// Auto-use template after login if use=true query param is present
|
||||
useEffect(() => {
|
||||
@@ -138,47 +184,50 @@ export default function TemplateDetails({
|
||||
const shouldAutoUse = searchParams?.get('use') === 'true'
|
||||
if (shouldAutoUse && currentUserId && !isUsing) {
|
||||
handleUseTemplate()
|
||||
// Clean up URL
|
||||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||||
}
|
||||
}, [searchParams, currentUserId, template?.id])
|
||||
|
||||
// Defensive check for template AFTER initializing hooks
|
||||
if (!template) {
|
||||
logger.error('Template prop is undefined or null in TemplateDetails component', {
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
})
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
|
||||
<p className='text-[#888888] text-[14px]'>
|
||||
The template you're looking for doesn't exist.
|
||||
</p>
|
||||
<p className='mt-[10px] text-[#888888] text-[12px]'>Template data failed to load</p>
|
||||
<div className='mb-[14px] font-medium text-[18px]'>Loading...</div>
|
||||
<p className='text-[14px] text-[var(--text-tertiary)]'>Fetching template details</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Template loaded in TemplateDetails', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
hasState: !!template.state,
|
||||
})
|
||||
if (!template) {
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
|
||||
<p className='text-[14px] text-[var(--text-tertiary)]'>
|
||||
The template you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const templateAuthor = template.author || template.creator?.name || 'Unknown'
|
||||
const templateAuthorType = template.authorType || template.creator?.referenceType || 'user'
|
||||
const templateDescription = template.description || template.details?.tagline || null
|
||||
const templateColor = template.color || 'var(--brand-primary)'
|
||||
const templateIcon = template.icon || 'Workflow'
|
||||
|
||||
const templateOwnerId =
|
||||
template.userId ||
|
||||
(template.creator?.referenceType === 'user' ? template.creator.referenceId : null)
|
||||
const isOwner = currentUserId && templateOwnerId === currentUserId
|
||||
|
||||
/**
|
||||
* Render workflow preview with consistent error handling
|
||||
*/
|
||||
const renderWorkflowPreview = () => {
|
||||
// Follow the same pattern as deployed-workflow-card.tsx
|
||||
if (!template?.state) {
|
||||
logger.info('Template has no state:', template)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-[#888888]'>
|
||||
<div className='text-[var(--text-tertiary)]'>
|
||||
<div className='mb-[10px] font-medium text-[14px]'>⚠️ No Workflow Data</div>
|
||||
<div className='text-[12px]'>This template doesn't contain workflow state data.</div>
|
||||
</div>
|
||||
@@ -186,11 +235,6 @@ export default function TemplateDetails({
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Template state:', template.state)
|
||||
logger.info('Template state type:', typeof template.state)
|
||||
logger.info('Template state blocks:', template.state.blocks)
|
||||
logger.info('Template state edges:', template.state.edges)
|
||||
|
||||
try {
|
||||
return (
|
||||
<WorkflowPreview
|
||||
@@ -207,7 +251,7 @@ export default function TemplateDetails({
|
||||
logger.error('Error rendering workflow preview:', error)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-[#888888]'>
|
||||
<div className='text-[var(--text-tertiary)]'>
|
||||
<div className='mb-[10px] font-medium text-[14px]'>⚠️ Preview Error</div>
|
||||
<div className='text-[12px]'>Unable to render workflow preview</div>
|
||||
</div>
|
||||
@@ -242,9 +286,7 @@ export default function TemplateDetails({
|
||||
const handleUseTemplate = async () => {
|
||||
if (isUsing) return
|
||||
|
||||
// Check if user is logged in
|
||||
if (!currentUserId) {
|
||||
// Redirect to login with callback URL to use template after login
|
||||
const callbackUrl = encodeURIComponent(
|
||||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||||
)
|
||||
@@ -265,8 +307,6 @@ export default function TemplateDetails({
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
@@ -280,19 +320,15 @@ export default function TemplateDetails({
|
||||
|
||||
setIsEditing(true)
|
||||
try {
|
||||
// If template already has a connected workflowId, check if it exists in user's workspace
|
||||
if (template.workflowId) {
|
||||
// Try to fetch the workflow to see if it still exists
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.ok) {
|
||||
// Workflow exists, redirect to it
|
||||
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No connected workflow or it was deleted - create a new one
|
||||
const response = await fetch(`/api/templates/${template.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -304,8 +340,6 @@ export default function TemplateDetails({
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error editing template:', error)
|
||||
@@ -317,34 +351,31 @@ export default function TemplateDetails({
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className='mb-[14px] flex items-center gap-[8px] text-[#888888] transition-colors hover:text-white'
|
||||
className='mb-[14px] flex items-center gap-[8px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
||||
<span className='font-medium text-[12px]'>Go back</span>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: template.color }}
|
||||
style={{ backgroundColor: templateColor }}
|
||||
>
|
||||
{getIconComponent(template.icon)}
|
||||
{getIconComponent(templateIcon)}
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>{template.name}</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>{template.description}</p>
|
||||
<p className='mt-[10px] font-base text-[14px] text-[var(--text-tertiary)]'>
|
||||
{templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats and Actions */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
{/* Stats */}
|
||||
<div className='flex items-center gap-[12px] font-medium text-[#888888] text-[12px]'>
|
||||
<div className='flex items-center gap-[12px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Eye className='h-[12px] w-[12px]' />
|
||||
<span>{template.views} views</span>
|
||||
@@ -355,9 +386,9 @@ export default function TemplateDetails({
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>by {template.author}</span>
|
||||
<span>by {templateAuthor}</span>
|
||||
</div>
|
||||
{template.authorType === 'organization' && (
|
||||
{templateAuthorType === 'organization' && (
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Users className='h-[12px] w-[12px]' />
|
||||
<span>Organization</span>
|
||||
@@ -407,16 +438,128 @@ export default function TemplateDetails({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{/* Workflow preview */}
|
||||
<div className='mt-[24px] flex-1'>
|
||||
{/* Creator Profile */}
|
||||
{template.creator && (
|
||||
<div className='mt-[24px]'>
|
||||
<h3 className='mb-[12px] font-medium text-[14px]'>Creator</h3>
|
||||
<div className='rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px]'>
|
||||
<div className='flex items-start gap-[16px]'>
|
||||
<div className='flex-shrink-0'>
|
||||
{template.creator.profileImageUrl ? (
|
||||
<div className='relative h-[56px] w-[56px] 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-[56px] w-[56px] items-center justify-center rounded-full bg-[var(--brand-primary)]'>
|
||||
<User className='h-[28px] w-[28px] text-white' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-1'>
|
||||
<h4 className='font-medium text-[14px]'>{template.creator.name}</h4>
|
||||
{template.creator.details?.about && (
|
||||
<p className='mt-[8px] text-[13px] text-[var(--text-tertiary)] leading-relaxed'>
|
||||
{template.creator.details.about}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(template.creator.details?.xUrl ||
|
||||
template.creator.details?.linkedinUrl ||
|
||||
template.creator.details?.websiteUrl ||
|
||||
template.creator.details?.contactEmail) && (
|
||||
<div className='mt-[12px] flex flex-wrap gap-[12px]'>
|
||||
{template.creator.details.xUrl && (
|
||||
<a
|
||||
href={template.creator.details.xUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<Twitter className='h-[14px] w-[14px]' />
|
||||
<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-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<Linkedin className='h-[14px] w-[14px]' />
|
||||
<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-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<Globe className='h-[14px] w-[14px]' />
|
||||
<span>Website</span>
|
||||
</a>
|
||||
)}
|
||||
{template.creator.details.contactEmail && (
|
||||
<a
|
||||
href={`mailto:${template.creator.details.contactEmail}`}
|
||||
className='inline-flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<Mail className='h-[14px] w-[14px]' />
|
||||
<span>Contact</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{template.details?.about && (
|
||||
<div className='mt-[24px]'>
|
||||
<h3 className='mb-[12px] font-medium text-[14px]'>Description</h3>
|
||||
<div className='prose prose-sm dark:prose-invert max-w-none text-[13px] text-[var(--text-secondary)]'>
|
||||
<ReactMarkdown>{template.details.about}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-[24px]'>
|
||||
<h2 className='mb-[14px] font-medium text-[14px]'>Workflow Preview</h2>
|
||||
<div className='h-[calc(100vh-280px)] w-full overflow-hidden rounded-[8px] bg-[#202020]'>
|
||||
<div className='h-[600px] w-full overflow-hidden rounded-[8px] bg-[var(--surface-3)]'>
|
||||
{renderWorkflowPreview()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required Credentials */}
|
||||
{Array.isArray(template.requiredCredentials) && template.requiredCredentials.length > 0 && (
|
||||
<div className='mt-[24px]'>
|
||||
<h3 className='mb-[12px] font-medium text-[14px]'>Credentials Needed</h3>
|
||||
<ul className='list-disc space-y-[4px] pl-[20px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{template.requiredCredentials.map((cred: CredentialRequirement, idx: number) => {
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -34,13 +34,13 @@ export default async function TemplatesPage() {
|
||||
effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
}
|
||||
|
||||
// Load templates (same logic as global page)
|
||||
// Load templates from database
|
||||
let rows:
|
||||
| Array<{
|
||||
id: string
|
||||
workflowId: string | null
|
||||
name: string
|
||||
details?: any
|
||||
details?: unknown
|
||||
creatorId: string | null
|
||||
creator: {
|
||||
id: string
|
||||
@@ -124,24 +124,46 @@ export default async function TemplatesPage() {
|
||||
row.creator?.referenceType === 'user' ? row.creator.referenceId : '' /* no owner context */
|
||||
|
||||
return {
|
||||
// New structure fields
|
||||
id: row.id,
|
||||
workflowId: row.workflowId,
|
||||
userId,
|
||||
name: row.name,
|
||||
description: row.details?.tagline ?? null,
|
||||
author: row.creator?.name ?? 'Unknown',
|
||||
authorType,
|
||||
organizationId,
|
||||
details: row.details as { tagline?: string; about?: string } | null,
|
||||
creatorId: row.creatorId,
|
||||
creator: row.creator
|
||||
? {
|
||||
id: row.creator.id,
|
||||
name: row.creator.name,
|
||||
profileImageUrl: row.creator.profileImageUrl,
|
||||
details: row.creator.details as {
|
||||
about?: string
|
||||
xUrl?: string
|
||||
linkedinUrl?: string
|
||||
websiteUrl?: string
|
||||
contactEmail?: string
|
||||
} | null,
|
||||
referenceType: row.creator.referenceType,
|
||||
referenceId: row.creator.referenceId,
|
||||
}
|
||||
: null,
|
||||
views: row.views,
|
||||
stars: row.stars,
|
||||
color: '#3972F6', // default color for workspace cards
|
||||
icon: 'Workflow', // default icon for workspace cards
|
||||
status: row.status,
|
||||
tags: row.tags,
|
||||
requiredCredentials: row.requiredCredentials,
|
||||
state: row.state as WorkspaceTemplate['state'],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isStarred: row.isStarred ?? false,
|
||||
isSuperUser: effectiveSuperUser,
|
||||
// Legacy fields for backward compatibility
|
||||
userId,
|
||||
description: (row.details as any)?.tagline ?? null,
|
||||
author: row.creator?.name ?? 'Unknown',
|
||||
authorType,
|
||||
organizationId,
|
||||
color: '#3972F6', // default color for workspace cards
|
||||
icon: 'Workflow', // default icon for workspace cards
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
|
||||
@@ -2,37 +2,55 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button, Input } from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
TemplateCard,
|
||||
TemplateCardSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
const logger = createLogger('TemplatesPage')
|
||||
|
||||
// Template data structure
|
||||
/**
|
||||
* Template data structure with support for both new and legacy fields
|
||||
*/
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string | null
|
||||
userId: string
|
||||
name: string
|
||||
description: string | null
|
||||
author: string
|
||||
authorType: 'user' | 'organization'
|
||||
organizationId: string | null
|
||||
details?: {
|
||||
tagline?: string
|
||||
about?: string
|
||||
} | null
|
||||
creatorId: string | null
|
||||
creator?: {
|
||||
id: string
|
||||
name: string
|
||||
profileImageUrl?: string | null
|
||||
details?: CreatorProfileDetails | null
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
} | null
|
||||
views: number
|
||||
stars: number
|
||||
color: string
|
||||
icon: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
tags: string[]
|
||||
requiredCredentials: unknown
|
||||
state: WorkflowState
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
isStarred: boolean
|
||||
isSuperUser?: boolean
|
||||
// Legacy fields for backward compatibility with existing UI
|
||||
userId?: string
|
||||
description?: string | null
|
||||
author?: string
|
||||
authorType?: 'user' | 'organization'
|
||||
organizationId?: string | null
|
||||
color?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface TemplatesProps {
|
||||
@@ -41,21 +59,23 @@ interface TemplatesProps {
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Templates list component displaying workflow templates
|
||||
* Supports filtering by tab (gallery/your/pending) and search
|
||||
*/
|
||||
export default function Templates({
|
||||
initialTemplates,
|
||||
currentUserId,
|
||||
isSuperUser,
|
||||
}: TemplatesProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('your')
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
}
|
||||
|
||||
// Handle star change callback from template card
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
@@ -64,111 +84,90 @@ export default function Templates({
|
||||
)
|
||||
}
|
||||
|
||||
// Get templates for the active tab with search filtering
|
||||
const getActiveTabTemplates = () => {
|
||||
let filtered = templates
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
*/
|
||||
const getFilteredTemplates = () => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
|
||||
// Filter by active tab
|
||||
if (activeTab === 'your') {
|
||||
filtered = filtered.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
)
|
||||
} else if (activeTab === 'gallery') {
|
||||
// Show all approved templates
|
||||
filtered = filtered.filter((template) => template.status === 'approved')
|
||||
} else if (activeTab === 'pending') {
|
||||
// Show pending templates for super users
|
||||
filtered = filtered.filter((template) => template.status === 'pending')
|
||||
}
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
: activeTab === 'gallery'
|
||||
? template.status === 'approved'
|
||||
: template.status === 'pending'
|
||||
|
||||
// Apply search filter
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.details?.tagline,
|
||||
template.author,
|
||||
template.creator?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}
|
||||
|
||||
const filteredTemplates = getFilteredTemplates()
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
*/
|
||||
const getEmptyStateMessage = () => {
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
const messages = {
|
||||
pending: {
|
||||
title: 'No pending templates',
|
||||
description: 'New submissions will appear here',
|
||||
},
|
||||
your: {
|
||||
title: 'No templates yet',
|
||||
description: 'Create or star templates to see them here',
|
||||
},
|
||||
gallery: {
|
||||
title: 'No templates available',
|
||||
description: 'Templates will appear once approved',
|
||||
},
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}
|
||||
|
||||
const activeTemplates = getActiveTabTemplates()
|
||||
|
||||
// Helper function to render template cards
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || ''}
|
||||
author={template.author}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
}
|
||||
|
||||
// Calculate counts for tabs
|
||||
const yourTemplatesCount = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
).length
|
||||
const galleryCount = templates.filter((template) => template.status === 'approved').length
|
||||
const pendingCount = templates.filter((template) => template.status === 'pending').length
|
||||
|
||||
const navigationTabs = [
|
||||
{
|
||||
id: 'gallery',
|
||||
label: 'Gallery',
|
||||
count: galleryCount,
|
||||
},
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your Templates',
|
||||
count: yourTemplatesCount,
|
||||
},
|
||||
...(isSuperUser
|
||||
? [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
count: pendingCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const emptyState = getEmptyStateMessage()
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<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 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>
|
||||
<h1 className='font-medium text-[18px]'>Templates</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
|
||||
<p className='mt-[10px] font-base text-[14px] text-[var(--text-tertiary)]'>
|
||||
Grab a template and start building, or make one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Badges */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
@@ -183,52 +182,60 @@ export default function Templates({
|
||||
<Button
|
||||
variant={activeTab === 'gallery' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => handleTabClick('gallery')}
|
||||
onClick={() => setActiveTab('gallery')}
|
||||
>
|
||||
Gallery
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'your' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => handleTabClick('your')}
|
||||
onClick={() => setActiveTab('your')}
|
||||
>
|
||||
Your Templates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{/* Templates Grid - Based on Active Tab */}
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading ? (
|
||||
renderSkeletonCards()
|
||||
) : activeTemplates.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<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='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery
|
||||
? 'No templates found'
|
||||
: activeTab === 'pending'
|
||||
? 'No pending templates'
|
||||
: activeTab === 'your'
|
||||
? 'No templates yet'
|
||||
: 'No templates available'}
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{emptyState.title}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: activeTab === 'pending'
|
||||
? 'New submissions will appear here'
|
||||
: activeTab === 'your'
|
||||
? 'Create or star templates to see them here'
|
||||
: 'Templates will appear once approved'}
|
||||
<p className='mt-1 text-[var(--text-tertiary)] text-xs'>
|
||||
{emptyState.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
activeTemplates.map((template) => renderTemplateCard(template))
|
||||
filteredTemplates.map((template) => {
|
||||
const author = template.author || template.creator?.name || 'Unknown'
|
||||
|
||||
return (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || template.details?.tagline || ''}
|
||||
author={author}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user