mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(templates): add share button, serve public templates routes for unauthenticated users and workspace one for authenticated users, improve settings style and organization (#1962)
* improvement(templates): add share button, serve public templates routes for unauthenticated users and workspace one for authenticated users, improve settings style and organization * fix lint --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
44
apps/sim/app/templates/[id]/layout.tsx
Normal file
44
apps/sim/app/templates/[id]/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface TemplateLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Template detail layout (public scope).
|
||||
* - If user is authenticated, redirect to workspace-scoped template detail.
|
||||
* - Otherwise render the public template detail children.
|
||||
*/
|
||||
export default async function TemplateDetailLayout({ children, params }: TemplateLayoutProps) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import TemplateDetails from './template'
|
||||
|
||||
/**
|
||||
* Public template detail page for unauthenticated users.
|
||||
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
|
||||
*/
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
Globe,
|
||||
Linkedin,
|
||||
Mail,
|
||||
Share2,
|
||||
Star,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Copy,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,6 +31,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
@@ -63,7 +72,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
|
||||
const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false)
|
||||
const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
|
||||
|
||||
const currentUserId = session?.user?.id || null
|
||||
|
||||
@@ -351,8 +360,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
// In workspace context, use current workspace directly
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
handleWorkspaceSelectForUse(workspaceId)
|
||||
} else {
|
||||
setShowWorkspaceSelectorForUse(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +422,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
if (isUsing || !template) return
|
||||
|
||||
setIsUsing(true)
|
||||
setShowWorkspaceSelectorForUse(false)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
@@ -518,6 +524,57 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
@@ -530,7 +587,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
|
||||
>
|
||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
||||
<span>Back</span>
|
||||
<span>More Templates</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -622,7 +679,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
<>
|
||||
{!currentUserId ? (
|
||||
<Button
|
||||
variant='active'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
@@ -645,48 +702,39 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
>
|
||||
{isUsing ? 'Creating...' : 'Use template'}
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
open={showWorkspaceSelectorForUse}
|
||||
onOpenChange={setShowWorkspaceSelectorForUse}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => setShowWorkspaceSelectorForUse(true)}
|
||||
disabled={isUsing || isLoadingWorkspaces}
|
||||
className='h-[32px] rounded-[6px] px-[16px] text-[#FFFFFF] text-[14px]'
|
||||
>
|
||||
{isUsing ? 'Creating...' : isLoadingWorkspaces ? 'Loading...' : 'Use'}
|
||||
<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={() => handleWorkspaceSelectForUse(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>
|
||||
)}
|
||||
) : 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>
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavigationTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface NavigationTabsProps {
|
||||
tabs: NavigationTab[]
|
||||
activeTab?: string
|
||||
onTabClick?: (tabId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
className={cn(
|
||||
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
|
||||
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
ChartNoAxesColumn,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
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('TemplateCard')
|
||||
|
||||
// Icon mapping for template icons
|
||||
const iconMap = {
|
||||
// Content & Documentation
|
||||
FileText,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Edit,
|
||||
|
||||
// Analytics & Charts
|
||||
BarChart3,
|
||||
LineChart,
|
||||
TrendingUp,
|
||||
Target,
|
||||
|
||||
// Database & Storage
|
||||
Database,
|
||||
Server,
|
||||
Cloud,
|
||||
Folder,
|
||||
|
||||
// Marketing & Communication
|
||||
Megaphone,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Bell,
|
||||
|
||||
// Sales & Finance
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
|
||||
// Support & Service
|
||||
HeadphonesIcon,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
Wrench,
|
||||
|
||||
// AI & Technology
|
||||
Bot,
|
||||
Brain,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
|
||||
// Workflow & Process
|
||||
Workflow,
|
||||
Search,
|
||||
Play,
|
||||
Layers,
|
||||
|
||||
// General
|
||||
Lightbulb,
|
||||
Star,
|
||||
Globe,
|
||||
Award,
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
@@ -119,78 +17,56 @@ interface TemplateCardProps {
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
tags?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
isAuthenticated?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
status?: 'pending' | 'approved' | 'rejected'
|
||||
isSuperUser?: boolean
|
||||
onApprove?: (templateId: string) => void
|
||||
onReject?: (templateId: string) => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
|
||||
{/* Left side - Info skeleton */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section skeleton */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Icon skeleton */}
|
||||
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
|
||||
{/* Title skeleton */}
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Star and Use button skeleton */}
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section skeleton */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
|
||||
{/* Stars section - hidden on smaller screens */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='h-[18px] w-[18px] animate-pulse rounded-[4px] bg-gray-700'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons skeleton */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='animate-pulse rounded bg-gray-200'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
/>
|
||||
))}
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
<div className='h-3 w-20 animate-pulse rounded bg-gray-700' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -211,13 +87,59 @@ const extractBlockTypesFromState = (state?: {
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get block display name
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
export function TemplateCard({
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
const normalizedBlocks: WorkflowState['blocks'] = {}
|
||||
for (const [id, raw] of Object.entries<any>(input.blocks || {})) {
|
||||
if (!raw || !raw.type) continue
|
||||
normalizedBlocks[id] = {
|
||||
id: raw.id ?? id,
|
||||
type: raw.type,
|
||||
name: raw.name ?? raw.type,
|
||||
position: raw.position ?? { x: 0, y: 0 },
|
||||
subBlocks: raw.subBlocks ?? {},
|
||||
outputs: raw.outputs ?? {},
|
||||
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true,
|
||||
horizontalHandles: raw.horizontalHandles,
|
||||
height: raw.height,
|
||||
advancedMode: raw.advancedMode,
|
||||
triggerMode: raw.triggerMode,
|
||||
data: raw.data ?? {},
|
||||
layout: raw.layout,
|
||||
}
|
||||
}
|
||||
|
||||
const normalized: WorkflowState = {
|
||||
blocks: normalizedBlocks,
|
||||
edges: Array.isArray(input.edges) ? input.edges : [],
|
||||
loops: input.loops ?? {},
|
||||
parallels: input.parallels ?? {},
|
||||
lastSaved: input.lastSaved,
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
@@ -225,18 +147,16 @@ export function TemplateCard({
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
tags = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
onTemplateUsed,
|
||||
status,
|
||||
isSuperUser,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
@@ -245,17 +165,39 @@ export function TemplateCard({
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
const [isApproving, setIsApproving] = useState(false)
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewRef.current) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '200px', threshold: 0 }
|
||||
)
|
||||
observer.observe(previewRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort()
|
||||
|
||||
// Determine if we're in a workspace context
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort(),
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
@@ -311,305 +253,162 @@ export function TemplateCard({
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles template use action
|
||||
* - In workspace context: Creates workflow instance via API
|
||||
* - Outside workspace: Navigates to template detail page
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
// Workspace context: Use API to create workflow instance
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
if (onTemplateUsed) {
|
||||
onTemplateUsed()
|
||||
}
|
||||
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
} else {
|
||||
// Non-workspace context: Navigate to template detail page
|
||||
router.push(`/templates/${id}`)
|
||||
return `/workspace/${workspaceId}/templates/${id}`
|
||||
}
|
||||
}
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handles card click navigation
|
||||
* - In workspace context: Navigate to workspace template detail
|
||||
* - Outside workspace: Navigate to global template detail
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates/${id}`)
|
||||
} else {
|
||||
router.push(`/templates/${id}`)
|
||||
}
|
||||
}
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles template approval (super user only)
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleApprove = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isApproving || !onApprove) return
|
||||
|
||||
setIsApproving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onApprove(id)
|
||||
} else {
|
||||
logger.error('Failed to approve template:', response.statusText)
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error approving template:', error)
|
||||
} finally {
|
||||
setIsApproving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles template rejection (super user only)
|
||||
*/
|
||||
const handleReject = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isRejecting || !onReject) return
|
||||
|
||||
setIsRejecting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/reject`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onReject(id)
|
||||
} else {
|
||||
logger.error('Failed to reject template:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting template:', error)
|
||||
} finally {
|
||||
setIsRejecting(false)
|
||||
}
|
||||
}
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
|
||||
'flex h-[142px]',
|
||||
className
|
||||
)}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Left side - Info */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Template name */}
|
||||
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
{/* Super user approval buttons for pending templates */}
|
||||
{isSuperUser && status === 'pending' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
|
||||
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isApproving ? '...' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isRejecting}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
|
||||
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isRejecting ? '...' : 'Reject'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Star button - only for authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors duration-50',
|
||||
localIsStarred
|
||||
? 'fill-yellow-500 text-yellow-500'
|
||||
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUseClick}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
)}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className='line-clamp-2 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className='mt-1 flex flex-wrap gap-1'>
|
||||
{tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant='secondary'
|
||||
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
|
||||
>
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-3 w-3 flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
) : (
|
||||
<User className='h-3 w-3 flex-shrink-0' />
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{author}</span>
|
||||
<span className='flex-shrink-0'>•</span>
|
||||
<ChartNoAxesColumn className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='flex-shrink-0'>{usageCount}</span>
|
||||
{/* Stars section - hidden on smaller screens when space is constrained */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<span>•</span>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[#2A2A2A]' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{blockTypes.length > 3 ? (
|
||||
<>
|
||||
{/* Show first 2 blocks when there are more than 3 */}
|
||||
{blockTypes.slice(0, 2).map((blockType, index) => {
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
marginLeft: index > 0 ? '-4px' : '0',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
>
|
||||
<span className='font-medium text-[10px] text-white'>+{blockTypes.length - 3}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n block for remaining blocks */}
|
||||
<div className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 3 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
key={index}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
marginLeft: index > 0 ? '-4px' : '0',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
||||
<User className='h-[18px] w-[18px] text-[#888888]' />
|
||||
</div>
|
||||
)}
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplateCard = memo(TemplateCardInner)
|
||||
|
||||
12
apps/sim/app/templates/layout-client.tsx
Normal file
12
apps/sim/app/templates/layout-client.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
|
||||
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} font-season`}>{children}</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
import TemplatesLayoutClient from './layout-client'
|
||||
|
||||
/**
|
||||
* Templates layout - server component wrapper for client layout.
|
||||
* Redirect logic is handled by individual pages to preserve paths.
|
||||
*/
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} font-season`}>{children}</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
return <TemplatesLayoutClient>{children}</TemplatesLayoutClient>
|
||||
}
|
||||
|
||||
@@ -1,99 +1,66 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import Templates from '@/app/templates/templates'
|
||||
|
||||
/**
|
||||
* Public templates list page.
|
||||
* Redirects authenticated users to their workspace-scoped templates page.
|
||||
* Allows unauthenticated users to view templates for SEO and discovery.
|
||||
*/
|
||||
export default async function TemplatesPage() {
|
||||
const session = await getSession()
|
||||
|
||||
// Check if user is a super user and if super user mode is enabled
|
||||
let effectiveSuperUser = false
|
||||
// Authenticated users: redirect to workspace-scoped templates
|
||||
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))
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
|
||||
// Effective super user = database status AND UI mode enabled
|
||||
effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch templates based on user status
|
||||
let templatesData
|
||||
|
||||
if (session?.user?.id) {
|
||||
// Build where condition based on super user status
|
||||
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
|
||||
|
||||
// Logged-in users: include star status
|
||||
templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
} else {
|
||||
// Non-logged-in users: only approved templates, no star status
|
||||
templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
}
|
||||
// Unauthenticated users: show public templates
|
||||
const templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={templatesData as unknown as Template[]}
|
||||
currentUserId={session?.user?.id || null}
|
||||
isSuperUser={effectiveSuperUser}
|
||||
currentUserId={null}
|
||||
isSuperUser={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, Search } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import { NavigationTabs } from '@/app/templates/components/navigation-tabs'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
@@ -60,11 +59,30 @@ export default function Templates({
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
}
|
||||
// Redirect authenticated users to workspace templates
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
const redirectToWorkspace = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const defaultWorkspace = data.workspaces?.[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}/templates`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error redirecting to workspace:', error)
|
||||
}
|
||||
}
|
||||
redirectToWorkspace()
|
||||
}
|
||||
}, [currentUserId, router])
|
||||
|
||||
// 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) =>
|
||||
@@ -73,239 +91,137 @@ export default function Templates({
|
||||
)
|
||||
}
|
||||
|
||||
const matchesSearch = (template: Template) => {
|
||||
if (!searchQuery) return true
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
template.name.toLowerCase().includes(query) ||
|
||||
template.details?.tagline?.toLowerCase().includes(query) ||
|
||||
template.creator?.name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const ownedTemplates = currentUserId
|
||||
? templates.filter(
|
||||
(template) =>
|
||||
template.creator?.referenceType === 'user' &&
|
||||
template.creator?.referenceId === currentUserId
|
||||
)
|
||||
: []
|
||||
const starredTemplates = currentUserId
|
||||
? templates.filter(
|
||||
(template) =>
|
||||
template.isStarred &&
|
||||
!(
|
||||
template.creator?.referenceType === 'user' &&
|
||||
template.creator?.referenceId === currentUserId
|
||||
)
|
||||
)
|
||||
: []
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab - only gallery and pending for public page
|
||||
const tabMatch =
|
||||
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
|
||||
|
||||
const filteredOwnedTemplates = ownedTemplates.filter(matchesSearch)
|
||||
const filteredStarredTemplates = starredTemplates.filter(matchesSearch)
|
||||
if (!tabMatch) return false
|
||||
|
||||
const galleryTemplates = templates
|
||||
.filter((template) => template.status === 'approved')
|
||||
.filter(matchesSearch)
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const pendingTemplates = templates
|
||||
.filter((template) => template.status === 'pending')
|
||||
.filter(matchesSearch)
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
// Helper function to render template cards
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
tags={template.tags}
|
||||
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
)
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery])
|
||||
|
||||
// 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 = ownedTemplates.length + starredTemplates.length
|
||||
const galleryCount = templates.filter((template) => template.status === 'approved').length
|
||||
const pendingCount = templates.filter((template) => template.status === 'pending').length
|
||||
|
||||
// Build tabs based on user status
|
||||
const navigationTabs = [
|
||||
{
|
||||
id: 'gallery',
|
||||
label: 'Gallery',
|
||||
count: galleryCount,
|
||||
},
|
||||
...(currentUserId
|
||||
? [
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your Templates',
|
||||
count: yourTemplatesCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperUser
|
||||
? [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
count: pendingCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
// Show tabs if there's more than one tab
|
||||
const showTabs = navigationTabs.length > 1
|
||||
|
||||
const handleBackToWorkspace = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const defaultWorkspace = data.workspaces?.[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}`)
|
||||
}
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error navigating to workspace:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = {
|
||||
pending: {
|
||||
title: 'No pending templates',
|
||||
description: 'New submissions will appear here',
|
||||
},
|
||||
gallery: {
|
||||
title: 'No templates available',
|
||||
description: 'Templates will appear once approved',
|
||||
},
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto p-6'>
|
||||
{/* Header with Back Button */}
|
||||
<div className='mb-6'>
|
||||
{currentUserId && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleBackToWorkspace}
|
||||
className='-ml-2 mb-4 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<ArrowLeft className='mr-2 h-4 w-4' />
|
||||
Back to Workspace
|
||||
</Button>
|
||||
)}
|
||||
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
|
||||
Templates
|
||||
</h1>
|
||||
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
|
||||
Grab a template and start building, or make
|
||||
<br />
|
||||
one from scratch.
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
|
||||
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Templates</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
|
||||
Grab a template and start building, or make one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<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)]' />
|
||||
<Input
|
||||
placeholder='Search templates...'
|
||||
placeholder='Search'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant={activeTab === 'gallery' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('gallery')}
|
||||
>
|
||||
Gallery
|
||||
</Button>
|
||||
{isSuperUser && (
|
||||
<Button
|
||||
variant={activeTab === 'pending' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('pending')}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation - only show if multiple tabs */}
|
||||
{showTabs && (
|
||||
<div className='mb-6'>
|
||||
<NavigationTabs
|
||||
tabs={navigationTabs}
|
||||
activeTab={activeTab}
|
||||
onTabClick={handleTabClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{loading ? (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{renderSkeletonCards()}
|
||||
</div>
|
||||
) : activeTab === 'your' ? (
|
||||
filteredOwnedTemplates.length === 0 && filteredStarredTemplates.length === 0 ? (
|
||||
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<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 ? (
|
||||
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-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery ? 'No templates found' : 'No templates yet'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: 'Create or star templates to see them here'}
|
||||
</p>
|
||||
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-8'>
|
||||
{filteredOwnedTemplates.length > 0 && (
|
||||
<section>
|
||||
<h2 className='mb-3 font-semibold text-lg'>Your Templates</h2>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{filteredOwnedTemplates.map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filteredStarredTemplates.length > 0 && (
|
||||
<section>
|
||||
<h2 className='mb-3 font-semibold text-lg'>Starred Templates</h2>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{filteredStarredTemplates.map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).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'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery
|
||||
? 'No templates found'
|
||||
: activeTab === 'pending'
|
||||
? 'No pending templates'
|
||||
: 'No templates available'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: activeTab === 'pending'
|
||||
? 'New submissions will appear here'
|
||||
: 'Templates will appear once approved'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,12 @@ interface TemplatePageProps {
|
||||
* Uses the shared TemplateDetails component with workspace context.
|
||||
*/
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId } = await params
|
||||
const { workspaceId, id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Require authentication
|
||||
// Redirect unauthenticated users to public template detail page
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login')
|
||||
redirect(`/templates/${id}`)
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
|
||||
@@ -21,9 +21,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Require authentication
|
||||
// Redirect unauthenticated users to public templates page
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login')
|
||||
redirect('/templates')
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CheckCircle2, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { CheckCircle2, Loader2, Plus } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
@@ -17,13 +17,11 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { TagInput } from '@/components/ui/tag-input'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
@@ -273,18 +271,23 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-4 py-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Template Connected</span>
|
||||
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] px-[16px] py-[12px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<CheckCircle2 className='h-[16px] w-[16px] text-green-600 dark:text-green-400' />
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Template Connected
|
||||
</span>
|
||||
{existingTemplate.status === 'pending' && (
|
||||
<span className='rounded-md bg-yellow-100 px-2 py-0.5 font-medium text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-yellow-300 bg-yellow-100 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
>
|
||||
Under Review
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
• {existingTemplate.views} views
|
||||
{existingTemplate.stars > 0 && ` • ${existingTemplate.stars} stars`}
|
||||
</span>
|
||||
@@ -294,11 +297,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className='h-8 px-2 text-muted-foreground hover:text-red-600 dark:hover:text-red-400'
|
||||
className='h-[32px] px-[8px] text-[var(--text-muted)] hover:text-red-600 dark:hover:text-red-400'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -362,8 +364,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-2'
|
||||
onClick={() => {
|
||||
try {
|
||||
const event = new CustomEvent('open-settings', {
|
||||
@@ -377,9 +377,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
})
|
||||
}
|
||||
}}
|
||||
className='gap-[8px]'
|
||||
>
|
||||
<Plus className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='text-muted-foreground'>Create a Creator Profile</span>
|
||||
<Plus className='h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
<span className='text-[var(--text-muted)]'>Create a Creator Profile</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -432,7 +433,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2 border-t pt-4'>
|
||||
<div className='flex justify-end gap-[8px] border-[var(--border)] border-t pt-[16px]'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -445,12 +446,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
variant='primary'
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
className='bg-purple-600 hover:bg-purple-700'
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
@@ -465,19 +466,21 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
|
||||
{showDeleteDialog && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
|
||||
<div className='w-full max-w-md rounded-lg bg-background p-6 shadow-lg'>
|
||||
<h3 className='mb-4 font-semibold text-lg'>Delete Template?</h3>
|
||||
<p className='mb-6 text-muted-foreground text-sm'>
|
||||
<div className='w-full max-w-md rounded-[8px] bg-[var(--surface-3)] p-[24px] shadow-lg'>
|
||||
<h3 className='mb-[16px] font-semibold text-[18px] text-[var(--text-primary)]'>
|
||||
Delete Template?
|
||||
</h3>
|
||||
<p className='mb-[24px] text-[14px] text-[var(--text-secondary)]'>
|
||||
This will permanently delete your template. This action cannot be undone.
|
||||
</p>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<div className='flex justify-end gap-[8px]'>
|
||||
<Button variant='outline' onClick={() => setShowDeleteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='bg-red-600 hover:bg-red-700'
|
||||
className='bg-red-600 text-white hover:bg-red-700'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Progress } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GRADIENT_BADGE_STYLES =
|
||||
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer'
|
||||
|
||||
// Constants matching UsageIndicator
|
||||
const PILL_COUNT = 8
|
||||
|
||||
interface UsageHeaderProps {
|
||||
title: string
|
||||
gradientTitle?: boolean
|
||||
@@ -43,17 +45,22 @@ export function UsageHeader({
|
||||
}: UsageHeaderProps) {
|
||||
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)
|
||||
|
||||
// Calculate filled pills based on usage percentage
|
||||
const filledPillsCount = Math.ceil((progress / 100) * PILL_COUNT)
|
||||
const isAlmostOut = filledPillsCount === PILL_COUNT
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
{/* Top row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
'font-medium text-[12px]',
|
||||
gradientTitle
|
||||
? 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
|
||||
: 'text-foreground'
|
||||
: 'text-[#FFFFFF]'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
@@ -67,25 +74,42 @@ export function UsageHeader({
|
||||
<span className='text-muted-foreground text-xs'>({seatsText})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
<div className='flex items-center gap-[4px] text-xs tabular-nums'>
|
||||
{isBlocked ? (
|
||||
<span className='text-muted-foreground'>Payment required</span>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px]'>Payment required</span>
|
||||
) : (
|
||||
<>
|
||||
<span className='text-muted-foreground'>${current.toFixed(2)}</span>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
{rightContent ?? <span className='text-muted-foreground'>${limit}</span>}
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
|
||||
${current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px]'>/</span>
|
||||
{rightContent ?? (
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
|
||||
${limit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={isBlocked ? 100 : progress}
|
||||
className='h-2'
|
||||
indicatorClassName='bg-black dark:bg-white'
|
||||
/>
|
||||
{/* Pills row - matching UsageIndicator */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: PILL_COUNT }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor: isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{isBlocked && (
|
||||
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui'
|
||||
import { Button, Combobox } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Label } from '@/components/ui'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
@@ -535,7 +536,7 @@ export function SSO() {
|
||||
// SSO Provider Status View
|
||||
<div className='space-y-4'>
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id} className='rounded-[12px] border border-border p-6'>
|
||||
<div key={provider.id} className='rounded-[8px] bg-muted/30 p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex-1'>
|
||||
<h3 className='font-medium text-base'>Single Sign-On Provider</h3>
|
||||
@@ -545,10 +546,9 @@ export function SSO() {
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onClick={() => handleReconfigure(provider)}
|
||||
className='rounded-[8px]'
|
||||
className='h-8'
|
||||
>
|
||||
Reconfigure
|
||||
</Button>
|
||||
@@ -609,12 +609,12 @@ export function SSO() {
|
||||
{hasProviders && (
|
||||
<div className='mb-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
setShowConfigForm(false)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
className='rounded-[8px]'
|
||||
className='h-8'
|
||||
>
|
||||
← Back to SSO Status
|
||||
</Button>
|
||||
@@ -626,14 +626,14 @@ export function SSO() {
|
||||
{/* Provider Type Selection */}
|
||||
<div className='space-y-1'>
|
||||
<Label>Provider Type</Label>
|
||||
<div className='flex rounded-[10px] border border-input bg-background p-1'>
|
||||
<div className='flex gap-2 rounded-[10px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-1'>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-colors',
|
||||
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-all',
|
||||
formData.providerType === 'oidc'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
? 'bg-[var(--surface-1)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-muted)]'
|
||||
: 'text-muted-foreground hover:bg-[var(--surface-2)] hover:text-foreground'
|
||||
)}
|
||||
onClick={() => handleInputChange('providerType', 'oidc')}
|
||||
>
|
||||
@@ -642,10 +642,10 @@ export function SSO() {
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-colors',
|
||||
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-all',
|
||||
formData.providerType === 'saml'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
? 'bg-[var(--surface-1)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-muted)]'
|
||||
: 'text-muted-foreground hover:bg-[var(--surface-2)] hover:text-foreground'
|
||||
)}
|
||||
onClick={() => handleInputChange('providerType', 'saml')}
|
||||
>
|
||||
@@ -661,24 +661,21 @@ export function SSO() {
|
||||
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='provider-id'>Provider ID</Label>
|
||||
<select
|
||||
id='provider-id'
|
||||
<Combobox
|
||||
value={formData.providerId}
|
||||
onChange={(e) => handleInputChange('providerId', e.target.value)}
|
||||
onChange={(value: string) => handleInputChange('providerId', value)}
|
||||
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
|
||||
label: id,
|
||||
value: id,
|
||||
}))}
|
||||
placeholder='Select a provider ID'
|
||||
editable={true}
|
||||
className={cn(
|
||||
'w-full rounded-[10px] border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showErrors &&
|
||||
errors.providerId.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
>
|
||||
<option value=''>Select a provider ID</option>
|
||||
{TRUSTED_SSO_PROVIDERS.map((providerId) => (
|
||||
<option key={providerId} value={providerId}>
|
||||
{providerId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{showErrors && errors.providerId.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.providerId.join(' ')}</p>
|
||||
@@ -808,16 +805,20 @@ export function SSO() {
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowClientSecret((s) => !s)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 h-6 w-6 rounded-[4px] p-0 text-muted-foreground hover:text-foreground'
|
||||
aria-label={
|
||||
showClientSecret ? 'Hide client secret' : 'Show client secret'
|
||||
}
|
||||
>
|
||||
{showClientSecret ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
{showClientSecret ? (
|
||||
<EyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<Eye className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{showErrors && errors.clientSecret.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
@@ -1011,7 +1012,7 @@ export function SSO() {
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='w-full rounded-[10px]'
|
||||
className='h-9 w-full'
|
||||
disabled={isLoading || hasAnyErrors(errors) || !isFormValid()}
|
||||
>
|
||||
{isLoading
|
||||
@@ -1029,29 +1030,18 @@ export function SSO() {
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure this URL in your identity provider as the callback/redirect URI
|
||||
</p>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='callback-url'
|
||||
readOnly
|
||||
value={callbackUrl}
|
||||
autoComplete='off'
|
||||
autoCapitalize='none'
|
||||
spellCheck={false}
|
||||
className='h-9 w-full cursor-text pr-10 font-mono text-xs focus-visible:ring-2 focus-visible:ring-primary/20'
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
<div className='relative flex h-9 items-center rounded-[8px] bg-muted px-3 pr-10'>
|
||||
<code className='flex-1 truncate font-mono text-foreground text-xs'>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={copyCallback}
|
||||
aria-label='Copy callback URL'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 rounded p-1 text-muted-foreground transition hover:text-foreground'
|
||||
className='absolute right-1 h-6 w-6 rounded-[4px] p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
{copied ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -437,7 +437,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
return (
|
||||
<div className='px-6 pt-4 pb-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* Current Plan & Usage Overview - Styled like usage-indicator */}
|
||||
{/* Current Plan & Usage Overview */}
|
||||
<div className='mb-2'>
|
||||
<UsageHeader
|
||||
title={formatPlanName(subscription.plan)}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CheckCircle, ChevronDown } from 'lucide-react'
|
||||
import { Button, Input, Label } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -140,127 +137,120 @@ export function MemberInvitationCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* Header - clean like account page */}
|
||||
<div>
|
||||
<h4 className='font-medium text-sm'>Invite Team Members</h4>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Add new members to your team and optionally give them access to specific workspaces
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)] p-4'>
|
||||
<div className='space-y-3'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h4 className='font-medium text-sm'>Invite Team Members</h4>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Add new members to your team and optionally give them access to specific workspaces
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main invitation input - clean layout */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex-1'>
|
||||
<div>
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn('w-full', emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
/>
|
||||
<div className='h-4 pt-1'>
|
||||
{emailError && (
|
||||
<p className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className='h-9 shrink-0 rounded-[8px] text-sm'
|
||||
>
|
||||
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
className='h-9 shrink-0 rounded-[8px]'
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : null}
|
||||
{hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-xs'>Workspace Access</h5>
|
||||
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
|
||||
Optional
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
Grant access to specific workspaces. You can modify permissions later.
|
||||
</p>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
showWorkspaceInvite && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
Workspaces
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed py-8 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>No workspaces available</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You need admin access to workspaces to invite members
|
||||
</p>
|
||||
{/* Workspace selection - collapsible */}
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-3 rounded-md border border-[var(--border-muted)] bg-[var(--surface-2)] p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-xs'>Workspace Access</h5>
|
||||
<span className='text-[11px] text-muted-foreground'>(Optional)</span>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-2 overflow-y-auto'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={workspace.id} className='flex items-center justify-between gap-2 py-1'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id={`workspace-${workspace.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onWorkspaceToggle(workspace.id, 'read')
|
||||
} else {
|
||||
onWorkspaceToggle(workspace.id, '')
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`workspace-${workspace.id}`}
|
||||
className='cursor-pointer font-medium text-sm'
|
||||
>
|
||||
{workspace.name}
|
||||
</Label>
|
||||
{workspace.isOwner && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='py-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>No workspaces available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-1.5 overflow-y-auto'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className='flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id={`workspace-${workspace.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onWorkspaceToggle(workspace.id, 'read')
|
||||
} else {
|
||||
onWorkspaceToggle(workspace.id, '')
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`workspace-${workspace.id}`}
|
||||
className='cursor-pointer text-xs'
|
||||
>
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
{workspace.name}
|
||||
</Label>
|
||||
{workspace.isOwner && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-[10px]'
|
||||
>
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Always reserve space for permission selector to maintain consistent layout */}
|
||||
<div className='flex h-[30px] w-32 flex-shrink-0 items-center justify-end gap-2'>
|
||||
{isSelected && (
|
||||
<PermissionSelector
|
||||
value={
|
||||
@@ -276,24 +266,25 @@ export function MemberInvitationCard({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteSuccess && (
|
||||
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
|
||||
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<AlertDescription>
|
||||
Invitation sent successfully
|
||||
{selectedCount > 0 &&
|
||||
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Success message */}
|
||||
{inviteSuccess && (
|
||||
<div className='flex items-start gap-2 rounded-md bg-green-500/10 p-2.5 text-green-600 dark:text-green-400'>
|
||||
<CheckCircle className='h-4 w-4 flex-shrink-0' />
|
||||
<p className='text-xs'>
|
||||
Invitation sent successfully
|
||||
{selectedCount > 0 &&
|
||||
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LogOut, UserX, X } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { UserX, X } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { Button as UIButton } from '@/components/ui/button'
|
||||
import { UserAvatar } from '@/components/user-avatar/user-avatar'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { Invitation, Member, Organization } from '@/stores/organization'
|
||||
|
||||
@@ -20,6 +21,8 @@ interface BaseItem {
|
||||
name: string
|
||||
email: string
|
||||
avatarInitial: string
|
||||
avatarUrl?: string | null
|
||||
userId?: string
|
||||
usage: string
|
||||
}
|
||||
|
||||
@@ -95,6 +98,8 @@ export function TeamMembers({
|
||||
name,
|
||||
email: member.user?.email || '',
|
||||
avatarInitial: name.charAt(0).toUpperCase(),
|
||||
avatarUrl: member.user?.image,
|
||||
userId: member.user?.id,
|
||||
usage: `$${usageAmount.toFixed(2)}`,
|
||||
role: member.role,
|
||||
member,
|
||||
@@ -118,6 +123,8 @@ export function TeamMembers({
|
||||
name: emailPrefix,
|
||||
email: invitation.email,
|
||||
avatarInitial: emailPrefix.charAt(0).toUpperCase(),
|
||||
avatarUrl: null,
|
||||
userId: invitation.email, // Use email as fallback for color generation
|
||||
usage: '-',
|
||||
invitation,
|
||||
}
|
||||
@@ -163,15 +170,12 @@ export function TeamMembers({
|
||||
{/* Member info */}
|
||||
<div className='flex flex-1 items-center gap-3'>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-medium text-sm ${
|
||||
item.type === 'member'
|
||||
? 'bg-primary/10 text-muted-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.avatarInitial}
|
||||
</div>
|
||||
<UserAvatar
|
||||
userId={item.userId || item.email}
|
||||
userName={item.name}
|
||||
avatarUrl={item.avatarUrl}
|
||||
size={32}
|
||||
/>
|
||||
|
||||
{/* Name and email */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
@@ -223,14 +227,14 @@ export function TeamMembers({
|
||||
item.email !== currentUserEmail && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
<UIButton
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
</Button>
|
||||
</UIButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='left'>Remove Member</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -240,7 +244,7 @@ export function TeamMembers({
|
||||
{isAdminOrOwner && item.type === 'invitation' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
<UIButton
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
@@ -252,7 +256,7 @@ export function TeamMembers({
|
||||
) : (
|
||||
<X className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</UIButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='left'>
|
||||
{cancellingInvitations.has(item.invitation.id)
|
||||
@@ -268,10 +272,9 @@ export function TeamMembers({
|
||||
|
||||
{/* Leave Organization button */}
|
||||
{canLeaveOrganization && (
|
||||
<div className='border-t pt-4'>
|
||||
<div className='mt-4 border-[var(--border-muted)] border-t pt-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='default'
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
if (!currentUserMember?.user?.id) {
|
||||
logger.error('Cannot leave organization: missing user ID', { currentUserMember })
|
||||
@@ -279,9 +282,7 @@ export function TeamMembers({
|
||||
}
|
||||
onRemoveMember(currentUserMember)
|
||||
}}
|
||||
className='w-full hover:bg-muted'
|
||||
>
|
||||
<LogOut className='mr-2 h-4 w-4' />
|
||||
Leave Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PILL_COUNT = 8
|
||||
|
||||
type Subscription = {
|
||||
id: string
|
||||
@@ -93,56 +94,59 @@ export function TeamSeatsOverview({
|
||||
)
|
||||
}
|
||||
|
||||
const totalSeats = subscriptionData.seats || 0
|
||||
const isEnterprise = checkEnterprisePlan(subscriptionData)
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
{/* Seats info and usage - matching team usage layout */}
|
||||
{/* Top row - matching UsageHeader */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Seats</span>
|
||||
{!checkEnterprisePlan(subscriptionData) ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
|
||||
</span>
|
||||
) : null}
|
||||
<span className='font-medium text-[#FFFFFF] text-[12px]'>Seats</span>
|
||||
{!isEnterprise && (
|
||||
<Badge
|
||||
className='gradient-text h-[1.125rem] cursor-pointer rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs'
|
||||
onClick={onAddSeatDialog}
|
||||
>
|
||||
Add Seats
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground'>{usedSeats} used</span>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
<span className='text-muted-foreground'>{subscriptionData.seats || 0} total</span>
|
||||
<div className='flex items-center gap-[4px] text-xs tabular-nums'>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
|
||||
{usedSeats} used
|
||||
</span>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px]'>/</span>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
|
||||
{totalSeats} total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - matching team usage component */}
|
||||
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
|
||||
{/* Pills row - one pill per seat */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: totalSeats }).map((_, i) => {
|
||||
const isFilled = i < usedSeats
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-[6px] flex-1 rounded-full transition-colors',
|
||||
isFilled ? 'bg-[#4285F4]' : 'bg-[#2C2C2C]'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons - below the usage display */}
|
||||
{checkEnterprisePlan(subscriptionData) ? (
|
||||
<div className='text-center'>
|
||||
{/* Enterprise message */}
|
||||
{isEnterprise && (
|
||||
<div className='pt-1 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Contact support for enterprise usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2 pt-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
className='h-8 flex-1 rounded-[8px]'
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onAddSeatDialog}
|
||||
disabled={isLoading}
|
||||
className='h-8 flex-1 rounded-[8px]'
|
||||
>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
onLimitUpdated={handleLimitUpdated}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs tabular-nums'>
|
||||
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
|
||||
${currentCap.toFixed(0)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TeamMembers,
|
||||
TeamSeats,
|
||||
TeamSeatsOverview,
|
||||
TeamUsage,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components'
|
||||
import { generateSlug, useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
@@ -275,112 +274,149 @@ export function TeamManagement() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col px-6 pt-4 pb-4'>
|
||||
<div className='flex flex-1 flex-col gap-6 overflow-y-auto'>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto px-6 pt-4 pb-4'>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Team Usage Overview */}
|
||||
<TeamUsage hasAdminAccess={adminOrOwner} />
|
||||
{/* Seats Overview - Full Width */}
|
||||
{adminOrOwner && (
|
||||
<div className='mb-4'>
|
||||
<TeamSeatsOverview
|
||||
subscriptionData={subscriptionData}
|
||||
isLoadingSubscription={isLoadingSubscription}
|
||||
usedSeats={usedSeats.used}
|
||||
isLoading={isLoading}
|
||||
onConfirmTeamUpgrade={confirmTeamUpgrade}
|
||||
onReduceSeats={handleReduceSeats}
|
||||
onAddSeatDialog={handleAddSeatDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
|
||||
{hasTeamPlan && !hasEnterprisePlan && (
|
||||
<div className='rounded-[8px] border bg-blue-50/50 p-4 shadow-xs dark:bg-blue-950/20'>
|
||||
<div className='space-y-3'>
|
||||
<h4 className='font-medium text-sm'>How Team Billing Works</h4>
|
||||
<ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'>
|
||||
<li>
|
||||
Your team is billed a minimum of $
|
||||
{(subscriptionData?.seats || 0) *
|
||||
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
|
||||
/month for {subscriptionData?.seats || 0} licensed seats
|
||||
</li>
|
||||
<li>All team member usage is pooled together from a shared limit</li>
|
||||
<li>
|
||||
When pooled usage exceeds the limit, all members are blocked from using the
|
||||
service
|
||||
</li>
|
||||
<li>You can increase the usage limit to allow for higher usage</li>
|
||||
<li>
|
||||
Any usage beyond the minimum seat cost is billed as overage at the end of the
|
||||
billing period
|
||||
</li>
|
||||
</ul>
|
||||
{/* Main Content: Team Members */}
|
||||
<div className='mb-4'>
|
||||
<TeamMembers
|
||||
organization={activeOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action: Invite New Members */}
|
||||
{adminOrOwner && (
|
||||
<div className='mb-4'>
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
isInviting={isInviting}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={userWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
|
||||
maxSeats={subscriptionData?.seats || 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Info - Subtle and collapsed */}
|
||||
<div className='space-y-3'>
|
||||
{/* Single Organization Notice */}
|
||||
{adminOrOwner && (
|
||||
<div className='rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)] p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<span className='font-medium'>Note:</span> Users can only be part of one
|
||||
organization at a time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Team Seats Overview */}
|
||||
{adminOrOwner && (
|
||||
<TeamSeatsOverview
|
||||
subscriptionData={subscriptionData}
|
||||
isLoadingSubscription={isLoadingSubscription}
|
||||
usedSeats={usedSeats.used}
|
||||
isLoading={isLoading}
|
||||
onConfirmTeamUpgrade={confirmTeamUpgrade}
|
||||
onReduceSeats={handleReduceSeats}
|
||||
onAddSeatDialog={handleAddSeatDialog}
|
||||
/>
|
||||
)}
|
||||
{/* Team Information */}
|
||||
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
|
||||
<span>Team Information</span>
|
||||
<svg
|
||||
className='h-4 w-4 transition-transform group-open:rotate-180'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
<div className='space-y-2 border-[var(--border-muted)] border-t p-3 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono text-[10px]'>{activeOrganization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
<span className='font-medium capitalize'>{userRole}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Team Members */}
|
||||
<TeamMembers
|
||||
organization={activeOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
|
||||
{/* Single Organization Notice */}
|
||||
{adminOrOwner && (
|
||||
<div className='mt-4 rounded-lg bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<span className='font-medium'>Note:</span> Users can only be part of one organization
|
||||
at a time. They must leave their current organization before joining another.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Invitation Card */}
|
||||
{adminOrOwner && (
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
isInviting={isInviting}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={userWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
|
||||
maxSeats={subscriptionData?.seats || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Information Section - pinned to bottom of modal */}
|
||||
<div className='mt-6 flex-shrink-0 border-t pt-6'>
|
||||
<div className='space-y-3 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono'>{activeOrganization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
<span className='font-medium capitalize'>{userRole}</span>
|
||||
</div>
|
||||
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
|
||||
{hasTeamPlan && !hasEnterprisePlan && (
|
||||
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
|
||||
<span>Billing Information</span>
|
||||
<svg
|
||||
className='h-4 w-4 transition-transform group-open:rotate-180'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</summary>
|
||||
<div className='border-[var(--border-muted)] border-t p-3'>
|
||||
<ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'>
|
||||
<li>
|
||||
Your team is billed a minimum of $
|
||||
{(subscriptionData?.seats || 0) *
|
||||
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
|
||||
/month for {subscriptionData?.seats || 0} licensed seats
|
||||
</li>
|
||||
<li>All team member usage is pooled together from a shared limit</li>
|
||||
<li>
|
||||
When pooled usage exceeds the limit, all members are blocked from using the
|
||||
service
|
||||
</li>
|
||||
<li>You can increase the usage limit to allow for higher usage</li>
|
||||
<li>
|
||||
Any usage beyond the minimum seat cost is billed as overage at the end of the
|
||||
billing period
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -32,6 +32,7 @@ export function useWorkspaceManagement({
|
||||
sessionUserId,
|
||||
}: UseWorkspaceManagementProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { switchToWorkspace } = useWorkflowRegistry()
|
||||
|
||||
// Workspace management state
|
||||
@@ -45,12 +46,14 @@ export function useWorkspaceManagement({
|
||||
// Refs to avoid dependency issues
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
|
||||
const pathnameRef = useRef<string | null>(pathname || null)
|
||||
const activeWorkspaceRef = useRef<Workspace | null>(null)
|
||||
const isInitializedRef = useRef<boolean>(false)
|
||||
|
||||
// Update refs when values change
|
||||
workspaceIdRef.current = workspaceId
|
||||
routerRef.current = router
|
||||
pathnameRef.current = pathname || null
|
||||
activeWorkspaceRef.current = activeWorkspace
|
||||
|
||||
/**
|
||||
@@ -189,7 +192,17 @@ export function useWorkspaceManagement({
|
||||
try {
|
||||
// Switch workspace and update URL
|
||||
await switchToWorkspace(workspace.id)
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/w`)
|
||||
const currentPath = pathnameRef.current || ''
|
||||
// Preserve templates route if user is on templates or template detail
|
||||
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
|
||||
if (templateDetailMatch) {
|
||||
const templateId = templateDetailMatch[1]
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`)
|
||||
} else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates`)
|
||||
} else {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/w`)
|
||||
}
|
||||
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
|
||||
} catch (error) {
|
||||
logger.error('Error switching workspace:', error)
|
||||
|
||||
@@ -347,13 +347,22 @@ export function Sidebar() {
|
||||
try {
|
||||
// Switch workspace and update URL
|
||||
await switchToWorkspace(workspace.id)
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/w`)
|
||||
const currentPath = pathname || ''
|
||||
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
|
||||
if (templateDetailMatch) {
|
||||
const templateId = templateDetailMatch[1]
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`)
|
||||
} else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates`)
|
||||
} else {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/w`)
|
||||
}
|
||||
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
|
||||
} catch (error) {
|
||||
logger.error('Error switching workspace:', error)
|
||||
}
|
||||
},
|
||||
[switchToWorkspace] // Removed activeWorkspace and router dependencies
|
||||
[switchToWorkspace, pathname] // include pathname
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -439,7 +439,26 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverScrollArea className='max-h-48 p-[4px]'>
|
||||
<PopoverScrollArea
|
||||
className='!flex-none max-h-48 p-[4px]'
|
||||
onWheelCapture={(e) => {
|
||||
// Ensure wheel events are captured and don't get blocked by parent handlers
|
||||
const target = e.currentTarget
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
const delta = e.deltaY
|
||||
const isScrollingDown = delta > 0
|
||||
const isScrollingUp = delta < 0
|
||||
|
||||
// Check if we're at scroll boundaries
|
||||
const isAtTop = scrollTop === 0
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
|
||||
|
||||
// Only stop propagation if we can scroll in the requested direction
|
||||
if ((isScrollingDown && !isAtBottom) || (isScrollingUp && !isAtTop)) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={dropdownRef} role='listbox'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-[14px]'>
|
||||
|
||||
@@ -278,7 +278,7 @@ const PopoverContent = React.forwardRef<
|
||||
sticky='partial'
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'z-[9999999] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
|
||||
'z-[10000001] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
|
||||
// If width is constrained by the caller, ensure inner flexible text truncates by default.
|
||||
hasUserWidthConstraint && '[&_.flex-1]:truncate',
|
||||
className
|
||||
|
||||
@@ -29,7 +29,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[10000000] bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
|
||||
className
|
||||
)}
|
||||
style={{ backdropFilter: 'blur(1.5px)', ...style }}
|
||||
@@ -70,7 +70,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[10000000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
|
||||
className
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
|
||||
66
apps/sim/components/user-avatar/user-avatar.tsx
Normal file
66
apps/sim/components/user-avatar/user-avatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
|
||||
interface UserAvatarProps {
|
||||
userId: string
|
||||
userName?: string | null
|
||||
avatarUrl?: string | null
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable user avatar component with error handling for image loading.
|
||||
* Falls back to colored circle with initials if image fails to load or is not available.
|
||||
*/
|
||||
export function UserAvatar({
|
||||
userId,
|
||||
userName,
|
||||
avatarUrl,
|
||||
size = 32,
|
||||
className = '',
|
||||
}: UserAvatarProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const color = getUserColor(userId)
|
||||
const initials = userName ? userName.charAt(0).toUpperCase() : '?'
|
||||
const hasAvatar = Boolean(avatarUrl) && !imageError
|
||||
|
||||
// Reset error state when avatar URL changes
|
||||
useEffect(() => {
|
||||
setImageError(false)
|
||||
}, [avatarUrl])
|
||||
|
||||
const fontSize = Math.max(10, size / 2.5)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white ${className}`}
|
||||
style={
|
||||
{
|
||||
background: hasAvatar ? undefined : color,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${fontSize}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasAvatar && avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={userName ? `${userName}'s avatar` : 'User avatar'}
|
||||
fill
|
||||
sizes={`${size}px`}
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
unoptimized={avatarUrl.startsWith('http')}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -157,6 +157,11 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
// Allow public access to workspace template pages - they handle their own redirects
|
||||
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface User {
|
||||
name?: string
|
||||
email?: string
|
||||
id?: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
|
||||
Reference in New Issue
Block a user