mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(templates): fix templates details page (#1942)
* Fix template details * Fix deps
This commit is contained in:
committed by
GitHub
parent
cb39e697e2
commit
79b318fd9c
@@ -51,7 +51,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -60,6 +59,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
@@ -110,14 +110,19 @@ const iconMap = {
|
||||
Award,
|
||||
}
|
||||
|
||||
export default function 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 [template, setTemplate] = useState<Template | null>(null)
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
|
||||
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
|
||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||
Array<{ organizationId: string; role: string }>
|
||||
@@ -139,6 +144,8 @@ export default function TemplateDetails() {
|
||||
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
|
||||
const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false)
|
||||
|
||||
const currentUserId = session?.user?.id || null
|
||||
|
||||
// Fetch template data on client side
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
@@ -156,28 +163,15 @@ export default function TemplateDetails() {
|
||||
setStarCount(data.data.stars || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching template:', 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) {
|
||||
console.error('Error fetching session:', error)
|
||||
setCurrentUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOrganizations = async () => {
|
||||
if (!currentUserId) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
@@ -192,11 +186,13 @@ export default function TemplateDetails() {
|
||||
setCurrentUserOrgRoles(orgRoles)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching organizations:', 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) {
|
||||
@@ -204,15 +200,14 @@ export default function TemplateDetails() {
|
||||
setIsSuperUser(data.isSuperUser || false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching super user status:', error)
|
||||
logger.error('Error fetching super user status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTemplate()
|
||||
fetchCurrentUser()
|
||||
fetchSuperUserStatus()
|
||||
fetchUserOrganizations()
|
||||
}, [templateId])
|
||||
}, [templateId, currentUserId])
|
||||
|
||||
// Fetch workspaces when user is logged in
|
||||
useEffect(() => {
|
||||
@@ -235,7 +230,7 @@ export default function TemplateDetails() {
|
||||
setWorkspaces(availableWorkspaces)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspaces:', error)
|
||||
logger.error('Error fetching workspaces:', error)
|
||||
} finally {
|
||||
setIsLoadingWorkspaces(false)
|
||||
}
|
||||
@@ -247,9 +242,14 @@ export default function TemplateDetails() {
|
||||
// Clean up URL when returning from login
|
||||
useEffect(() => {
|
||||
if (template && searchParams?.get('use') === 'true' && currentUserId) {
|
||||
router.replace(`/templates/${template.id}`)
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForUse(workspaceId)
|
||||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||||
} else {
|
||||
router.replace(`/templates/${template.id}`)
|
||||
}
|
||||
}
|
||||
}, [searchParams, currentUserId, template, router])
|
||||
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
|
||||
|
||||
// Check if user can edit template
|
||||
const canEditTemplate = (() => {
|
||||
@@ -355,7 +355,7 @@ export default function TemplateDetails() {
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error rendering workflow preview:', 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'>
|
||||
@@ -368,7 +368,11 @@ export default function TemplateDetails() {
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/templates')
|
||||
if (isWorkspaceContext) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
@@ -392,37 +396,59 @@ export default function TemplateDetails() {
|
||||
|
||||
const handleUseTemplate = () => {
|
||||
if (!currentUserId) {
|
||||
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
? encodeURIComponent(`/workspace/${workspaceId}/templates/${template.id}?use=true`)
|
||||
: encodeURIComponent(`/templates/${template.id}`)
|
||||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||||
return
|
||||
}
|
||||
setShowWorkspaceSelectorForUse(true)
|
||||
|
||||
// In workspace context, use current workspace directly
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForUse(workspaceId)
|
||||
} else {
|
||||
setShowWorkspaceSelectorForUse(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTemplate = async () => {
|
||||
if (!currentUserId || !template) return
|
||||
|
||||
// Check if workflow exists and user has access
|
||||
if (template.workflowId) {
|
||||
// In workspace context with existing workflow, navigate directly
|
||||
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 workflow doesn't exist, fall through to workspace selector
|
||||
}
|
||||
|
||||
// Check if workflow exists and user has access (global context)
|
||||
if (template.workflowId && !isWorkspaceContext) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.status === 403) {
|
||||
// User doesn't have access to the workspace
|
||||
// This shouldn't happen if button is properly disabled, but handle it gracefully
|
||||
alert("You don't have access to the workspace containing this template")
|
||||
return
|
||||
}
|
||||
|
||||
if (checkResponse.ok) {
|
||||
// Workflow exists and user has access, get its workspace and navigate to it
|
||||
const result = await checkResponse.json()
|
||||
const workspaceId = result.data?.workspaceId
|
||||
if (workspaceId) {
|
||||
// Use window.location to ensure a full page load with fresh data
|
||||
// This avoids race conditions with client-side navigation
|
||||
window.location.href = `/workspace/${workspaceId}/w/${template.workflowId}`
|
||||
const templateWorkspaceId = result.data?.workspaceId
|
||||
if (templateWorkspaceId) {
|
||||
window.location.href = `/workspace/${templateWorkspaceId}/w/${template.workflowId}`
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -433,8 +459,12 @@ export default function TemplateDetails() {
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow doesn't exist or was deleted - show workspace selector
|
||||
setShowWorkspaceSelectorForEdit(true)
|
||||
// Workflow doesn't exist - show workspace selector or use current workspace
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForEdit(workspaceId)
|
||||
} else {
|
||||
setShowWorkspaceSelectorForEdit(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkspaceSelectForUse = async (workspaceId: string) => {
|
||||
@@ -505,7 +535,11 @@ export default function TemplateDetails() {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'approved' })
|
||||
// Redirect back to templates page after approval
|
||||
router.push('/templates')
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error approving template:', error)
|
||||
@@ -527,7 +561,11 @@ export default function TemplateDetails() {
|
||||
// Update template status optimistically
|
||||
setTemplate({ ...template, status: 'rejected' })
|
||||
// Redirect back to templates page after rejection
|
||||
router.push('/templates')
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting template:', error)
|
||||
@@ -537,7 +575,7 @@ export default function TemplateDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
<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'>
|
||||
@@ -621,32 +659,17 @@ export default function TemplateDetails() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit button - for template owners (approved or pending) */}
|
||||
{/* Edit button - for template owners */}
|
||||
{canEditTemplate && currentUserId && (
|
||||
<>
|
||||
{template.workflowId && !showWorkspaceSelectorForEdit ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
onClick={handleEditTemplate}
|
||||
disabled={isEditing || hasWorkspaceAccess === false}
|
||||
className={
|
||||
hasWorkspaceAccess === false
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}
|
||||
>
|
||||
{isEditing ? 'Opening...' : 'Edit Template'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
{hasWorkspaceAccess === false && (
|
||||
<Tooltip.Content>
|
||||
<p>Don't have access to workspace to edit template</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
{(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? (
|
||||
<Button
|
||||
onClick={handleEditTemplate}
|
||||
disabled={isEditing || (!isWorkspaceContext && hasWorkspaceAccess === false)}
|
||||
className='bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
{isEditing ? 'Opening...' : 'Edit Template'}
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
open={showWorkspaceSelectorForEdit}
|
||||
@@ -654,9 +677,7 @@ export default function TemplateDetails() {
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
!template.workflowId && setShowWorkspaceSelectorForEdit(true)
|
||||
}
|
||||
onClick={() => setShowWorkspaceSelectorForEdit(true)}
|
||||
disabled={isUsing || isLoadingWorkspaces}
|
||||
className='bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
@@ -701,13 +722,26 @@ export default function TemplateDetails() {
|
||||
{!currentUserId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
? encodeURIComponent(
|
||||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||||
)
|
||||
: encodeURIComponent(`/templates/${template.id}`)
|
||||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||||
}}
|
||||
className='bg-purple-600 text-white hover:bg-purple-700'
|
||||
>
|
||||
Sign in to use
|
||||
</Button>
|
||||
) : isWorkspaceContext ? (
|
||||
<Button
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
className='bg-purple-600 text-white hover:bg-purple-700'
|
||||
>
|
||||
{isUsing ? 'Creating...' : 'Use this template'}
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
open={showWorkspaceSelectorForUse}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} font-season`}>{children}</div>
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} font-season`}>{children}</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped template detail page.
|
||||
* Data fetching is handled client-side in the TemplateDetails component.
|
||||
* Requires authentication and workspace membership to access.
|
||||
* Uses the shared TemplateDetails component with workspace context.
|
||||
*/
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Require authentication
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <TemplateDetails isWorkspaceContext={true} />
|
||||
}
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
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 { 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')
|
||||
|
||||
// Icon mapping - reuse from template-card
|
||||
const iconMap = {
|
||||
FileText,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Edit,
|
||||
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,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon component from template icon name
|
||||
*/
|
||||
const getIconComponent = (icon: string): React.ReactNode => {
|
||||
const IconComponent = iconMap[icon as keyof typeof iconMap]
|
||||
return IconComponent ? (
|
||||
<IconComponent className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<FileText className='h-[14px] w-[14px]' />
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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)
|
||||
|
||||
// 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(() => {
|
||||
if (!template?.id) return
|
||||
const shouldAutoUse = searchParams?.get('use') === 'true'
|
||||
if (shouldAutoUse && currentUserId && !isUsing) {
|
||||
handleUseTemplate()
|
||||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||||
}
|
||||
}, [searchParams, currentUserId, template?.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<div className='mb-[14px] font-medium text-[18px]'>Loading...</div>
|
||||
<p className='text-[14px] text-[var(--text-tertiary)]'>Fetching template details</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const renderWorkflowPreview = () => {
|
||||
if (!template?.state) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state as WorkflowState}
|
||||
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-[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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring || !currentUserId) return
|
||||
|
||||
setIsStarring(true)
|
||||
try {
|
||||
const method = isStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${template.id}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
setIsStarred(!isStarred)
|
||||
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseTemplate = async () => {
|
||||
if (isUsing) return
|
||||
|
||||
if (!currentUserId) {
|
||||
const callbackUrl = encodeURIComponent(
|
||||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||||
)
|
||||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||||
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()
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
} finally {
|
||||
setIsUsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTemplate = async () => {
|
||||
if (isEditing || !currentUserId) return
|
||||
|
||||
setIsEditing(true)
|
||||
try {
|
||||
if (template.workflowId) {
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.ok) {
|
||||
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/templates/${template.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to edit template')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error editing template:', error)
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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]'>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
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>
|
||||
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div
|
||||
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: templateColor }}
|
||||
>
|
||||
{getIconComponent(templateIcon)}
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>{template.name}</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[14px] text-[var(--text-tertiary)]'>
|
||||
{templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<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>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Star className='h-[12px] w-[12px]' />
|
||||
<span>{starCount} stars</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>by {templateAuthor}</span>
|
||||
</div>
|
||||
{templateAuthorType === 'organization' && (
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Users className='h-[12px] w-[12px]' />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{/* Star button - only for logged-in users */}
|
||||
{currentUserId && (
|
||||
<Button
|
||||
variant={isStarred ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleStarToggle}
|
||||
disabled={isStarring}
|
||||
>
|
||||
<Star className={cn('mr-[6px] h-[14px] w-[14px]', isStarred && 'fill-current')} />
|
||||
<span className='font-medium text-[12px]'>{starCount}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit button - only for template owner when logged in */}
|
||||
{isOwner && currentUserId && (
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleEditTemplate}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<Edit className='mr-[6px] h-[14px] w-[14px]' />
|
||||
<span className='font-medium text-[12px]'>{isEditing ? 'Opening...' : 'Edit'}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Use template button */}
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
>
|
||||
<span className='font-medium text-[12px]'>
|
||||
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{/* 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-[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>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,53 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped Templates page.
|
||||
*
|
||||
* Mirrors the global templates data loading while rendering the workspace
|
||||
* templates UI (which accounts for the sidebar layout). This avoids redirecting
|
||||
* to the global /templates route and keeps users within their workspace context.
|
||||
* Requires authentication and workspace membership to access.
|
||||
*/
|
||||
export default async function TemplatesPage() {
|
||||
export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
let effectiveSuperUser = false
|
||||
if (session?.user?.id) {
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
// Require authentication
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
|
||||
// Load templates from database
|
||||
let rows:
|
||||
| Array<{
|
||||
|
||||
@@ -193,6 +193,15 @@ export default function Templates({
|
||||
>
|
||||
Your Templates
|
||||
</Button>
|
||||
{isSuperUser && (
|
||||
<Button
|
||||
variant={activeTab === 'pending' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('pending')}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user