Compare commits

...

20 Commits
v0.5 ... v0.5.2

Author SHA1 Message Date
Waleed
66c8fa2a77 v0.5.2: templates, wand, settings, table, copilot-landing, files, deploy, autoconnect 2025-11-12 16:36:18 -08:00
Waleed
766c7fbfbb feat(drizzle): added ods for analytics from drizzle (#1956)
* feat(drizzle): added ods for analytics from drizzle

* clean
2025-11-12 16:35:46 -08:00
Adam Gough
675c42188a feat(newgifs): added new gifs (#1953)
* new gifs

* changed wording

* changed wording

* lowercase

* changed wording

* remove blog stuff

---------

Co-authored-by: aadamgough <adam@sim.ai>
Co-authored-by: waleed <walif6@gmail.com>
2025-11-12 16:21:09 -08:00
Emir Karabeg
f414ae1936 improvement: template use button (#1954) 2025-11-12 16:17:27 -08:00
Emir Karabeg
ead0db9d2a improvement: templates styling (#1952) 2025-11-12 15:58:38 -08:00
Vikhyath Mondreti
10288111a8 fix(autoconnect): should check if triggermode is set from the toolbar drag event directly (#1951) 2025-11-12 15:51:52 -08:00
Siddharth Ganesan
01183f1771 fix(executor): consolidate execution hooks (#1950) 2025-11-12 15:46:29 -08:00
Waleed
ff081714e4 fix(deploy): fix button (#1949) 2025-11-12 14:16:56 -08:00
Emir Karabeg
36bcd75832 improvement: usage-indicator UI (#1948) 2025-11-12 14:09:18 -08:00
Waleed
9db969b1e0 fix(files): changed file input value sample from string -> object (#1947) 2025-11-12 14:06:33 -08:00
Waleed
2fbe0de5d3 fix(settings): fix broken api keys, help modal, logs, workflow renaming (#1945)
* fix(settings): fix broken api keys, help modal, logs, workflow renaming

* fix build

* cleanup

* use emcn
2025-11-12 13:43:48 -08:00
Vikhyath Mondreti
6315cc105b fix(wand): subblocks should not be overwritten after wand gen (#1946) 2025-11-12 13:36:36 -08:00
Vikhyath Mondreti
61404d48a3 fix(landing): need to propagate landing page copilot prompt (#1944) 2025-11-12 12:36:45 -08:00
Vikhyath Mondreti
dbf9097a5b fix(templates-details): restore approval feature, and keep details UI consistent, smoothen out creation of profile (#1943)
* fix(templates): view current ui

* update UI to be less cluttered

* make state management for creating user profile smoother

* fix autoselect logic

* fix lint
2025-11-12 12:22:16 -08:00
Siddharth Ganesan
79b318fd9c fix(templates): fix templates details page (#1942)
* Fix template details

* Fix deps
2025-11-12 11:16:15 -08:00
Siddharth Ganesan
cb39e697e2 fix(templates): fix template details page (#1940)
* Fix v1

* Template details page
2025-11-12 10:45:34 -08:00
Emir Karabeg
e1a46c90c6 fix: table subblock (#1937) 2025-11-12 09:53:38 -08:00
Waleed
c7560be282 fix(presence): fix additional avatars showing for presence (#1938) 2025-11-12 09:33:01 -08:00
Waleed
63f18995da v0.5.1: blogs 2025-11-12 08:30:33 -08:00
Waleed
af501347bb feat(blogs): added blog tags (#1935) 2025-11-12 08:29:03 -08:00
91 changed files with 2798 additions and 3415 deletions

View File

@@ -13,8 +13,25 @@
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
### Build Workflows with Ease
Design agent workflows visually on a canvas—connect agents, tools, and blocks, then run them instantly.
<p align="center">
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
<img src="apps/sim/public/static/workflow.gif" alt="Workflow Builder Demo" width="800"/>
</p>
### Supercharge with Copilot
Leverage Copilot to generate nodes, fix errors, and iterate on flows directly from natural language.
<p align="center">
<img src="apps/sim/public/static/copilot.gif" alt="Copilot Demo" width="800"/>
</p>
### Integrate Vector Databases
Upload documents to a vector store and let agents answer questions grounded in your specific content.
<p align="center">
<img src="apps/sim/public/static/knowledge.gif" alt="Knowledge Uploads and Retrieval Demo" width="800"/>
</p>
## Quickstart

View File

@@ -6,6 +6,7 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
import { PostHogProvider } from '@/lib/posthog/provider'
import '@/app/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { SessionProvider } from '@/lib/session/session-context'
import { season } from '@/app/fonts/season/season'
import { HydrationErrorHandler } from '@/app/hydration-error-handler'
@@ -55,6 +56,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta name='format-detection' content='telephone=no' />
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
{/* OneDollarStats Analytics */}
<script defer src='https://assets.onedollarstats.com/stonks.js' />
{/* Blocking script to prevent sidebar dimensions flash on page load */}
<script
dangerouslySetInnerHTML={{
@@ -166,6 +170,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body className={`${season.variable} font-season`} suppressHydrationWarning>
<HydrationErrorHandler />
<OneDollarStats />
<PostHogProvider>
<ThemeProvider>
<SessionProvider>

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import {
Brain,
Briefcase,
Calculator,
ChartNoAxesColumn,
Cloud,
Code,
Cpu,
@@ -41,7 +42,7 @@ import {
Wrench,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
@@ -115,6 +116,7 @@ interface TemplateCardProps {
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
blocks?: string[]
@@ -126,6 +128,11 @@ interface TemplateCardProps {
isStarred?: boolean
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
isAuthenticated?: boolean
onTemplateUsed?: () => void
status?: 'pending' | 'approved' | 'rejected'
isSuperUser?: boolean
onApprove?: (templateId: string) => void
onReject?: (templateId: string) => void
}
// Skeleton component for loading states
@@ -215,6 +222,7 @@ export function TemplateCard({
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
blocks = [],
@@ -224,13 +232,21 @@ export function TemplateCard({
isStarred = false,
onStarChange,
isAuthenticated = true,
onTemplateUsed,
status,
isSuperUser,
onApprove,
onReject,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const [isApproving, setIsApproving] = useState(false)
const [isRejecting, setIsRejecting] = useState(false)
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
@@ -238,6 +254,9 @@ export function TemplateCard({
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Determine if we're in a workspace context
const workspaceId = params?.workspaceId as string | undefined
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
@@ -291,20 +310,119 @@ export function TemplateCard({
}
}
// Handle use click - just navigate to detail page
/**
* Handles template use action
* - In workspace context: Creates workflow instance via API
* - Outside workspace: Navigates to template detail page
*/
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
router.push(`/templates/${id}`)
if (workspaceId) {
// Workspace context: Use API to create workflow instance
try {
const response = await fetch(`/api/templates/${id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
if (onTemplateUsed) {
onTemplateUsed()
}
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
}
} else {
// Non-workspace context: Navigate to template detail page
router.push(`/templates/${id}`)
}
}
/**
* Handles card click navigation
* - In workspace context: Navigate to workspace template detail
* - Outside workspace: Navigate to global template detail
*/
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
router.push(`/templates/${id}`)
if (workspaceId) {
router.push(`/workspace/${workspaceId}/templates/${id}`)
} else {
router.push(`/templates/${id}`)
}
}
/**
* Handles template approval (super user only)
*/
const handleApprove = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isApproving || !onApprove) return
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${id}/approve`, {
method: 'POST',
})
if (response.ok) {
onApprove(id)
} else {
logger.error('Failed to approve template:', response.statusText)
}
} catch (error) {
logger.error('Error approving template:', error)
} finally {
setIsApproving(false)
}
}
/**
* Handles template rejection (super user only)
*/
const handleReject = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isRejecting || !onReject) return
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${id}/reject`, {
method: 'POST',
})
if (response.ok) {
onReject(id)
} else {
logger.error('Failed to reject template:', response.statusText)
}
} catch (error) {
logger.error('Error rejecting template:', error)
} finally {
setIsRejecting(false)
}
}
return (
@@ -330,29 +448,57 @@ export function TemplateCard({
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
{/* Super user approval buttons for pending templates */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
isStarLoading && 'opacity-50'
)}
/>
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</div>
</div>
@@ -387,10 +533,16 @@ export function TemplateCard({
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
{authorImageUrl ? (
<div className='h-3 w-3 flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<User className='h-3 w-3 flex-shrink-0' />
)}
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<ChartNoAxesColumn className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>

View File

@@ -1,5 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/fonts/season/season'
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return <div className={`${season.variable} font-season`}>{children}</div>
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} font-season`}>{children}</div>
</Tooltip.Provider>
)
}

View File

@@ -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>
)
}

View File

@@ -1,564 +0,0 @@
import { useState } from 'react'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks/registry'
const logger = createLogger('TemplateCard')
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
description: string
author: string
usageCount: string
stars?: number
blocks?: string[]
onClick?: () => void
className?: string
// Add state prop to extract block types
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// Super user props for approval
status?: 'pending' | 'approved' | 'rejected'
isSuperUser?: boolean
onApprove?: (templateId: string) => void
onReject?: (templateId: string) => void
}
// Skeleton component for loading states
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
</div>
</div>
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get block display name
const getBlockDisplayName = (blockType: string): string => {
const block = getBlock(blockType)
return block?.name || blockType
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
export function TemplateCard({
id,
title,
description,
author,
usageCount,
stars = 0,
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
status,
isSuperUser,
onApprove,
onReject,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
const [isApproving, setIsApproving] = useState(false)
const [isRejecting, setIsRejecting] = useState(false)
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
}
// Handle use template
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
const response = await fetch(`/api/templates/${id}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: params.workspaceId,
}),
})
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
// Call the callback if provided (for closing modals, etc.)
if (onTemplateUsed) {
onTemplateUsed()
}
// Use window.location.href for more reliable navigation
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
}
}
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
const workspaceId = params?.workspaceId as string
if (workspaceId) {
router.push(`/workspace/${workspaceId}/templates/${id}`)
}
}
const handleApprove = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isApproving || !onApprove) return
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${id}/approve`, {
method: 'POST',
})
if (response.ok) {
onApprove(id)
}
} catch (error) {
logger.error('Error approving template:', error)
} finally {
setIsApproving(false)
}
}
const handleReject = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isRejecting || !onReject) return
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${id}/reject`, {
method: 'POST',
})
if (response.ok) {
onReject(id)
}
} catch (error) {
logger.error('Error rejecting template:', error)
} finally {
setIsRejecting(false)
}
}
return (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Approve/Reject buttons for pending templates (super users only) */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
</div>
</div>
{/* Description */}
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -120,6 +120,7 @@ export default function Templates({
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
tags={template.tags}

View File

@@ -7,24 +7,25 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const pathname = usePathname()
// Force dark mode for workspace pages
// Force dark mode for workspace pages and templates
// Force light mode for certain public pages
const forcedTheme = pathname.startsWith('/workspace')
? 'dark'
: pathname === '/' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/sso') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/careers') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio')
? 'light'
: undefined
const forcedTheme =
pathname.startsWith('/workspace') || pathname.startsWith('/templates')
? 'dark'
: pathname === '/' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/sso') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/careers') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio')
? 'light'
: undefined
return (
<NextThemesProvider

View File

@@ -16,7 +16,7 @@ import {
RotateCcw,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
@@ -28,7 +28,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { SearchHighlight } from '@/components/ui/search-highlight'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
@@ -1006,7 +1005,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleRetryDocument(doc.id)
@@ -1024,7 +1022,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(doc.id)
@@ -1059,7 +1056,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleDeleteDocument(doc.id)
@@ -1070,7 +1066,7 @@ export function KnowledgeBase({
}
className='h-8 w-8 p-0 text-gray-500 hover:text-red-600 disabled:opacity-50'
>
<Trash className='h-4 w-4' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -1097,7 +1093,6 @@ export function KnowledgeBase({
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
onClick={prevPage}
disabled={!hasPrevPage || isLoadingDocuments}
className='h-8 w-8 p-0'
@@ -1138,7 +1133,6 @@ export function KnowledgeBase({
<Button
variant='ghost'
size='sm'
onClick={nextPage}
disabled={!hasNextPage || isLoadingDocuments}
className='h-8 w-8 p-0'

View File

@@ -1,13 +1,12 @@
'use client'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/utils'
interface PrimaryButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
size?: 'sm' | 'default' | 'lg'
className?: string
type?: 'button' | 'submit' | 'reset'
}
@@ -16,7 +15,6 @@ export function PrimaryButton({
children,
onClick,
disabled = false,
size = 'sm',
className,
type = 'button',
}: PrimaryButtonProps) {
@@ -25,9 +23,9 @@ export function PrimaryButton({
type={type}
onClick={onClick}
disabled={disabled}
size={size}
variant='primary'
className={cn(
'flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
className
)}

View File

@@ -3,8 +3,7 @@
import { useMemo, useState } from 'react'
import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Button, Tooltip } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -111,7 +110,7 @@ export function Knowledge() {
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import { Loader2, RefreshCw, Search } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Button, Tooltip } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { soehne } from '@/app/fonts/soehne/soehne'
@@ -49,7 +48,7 @@ export function Controls({
placeholder='Search workflows...'
value={searchQuery}
onChange={(e) => setSearchQuery?.(e.target.value)}
className='h-9 w-full rounded-[11px] border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='h-9 w-full border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
/>
{searchQuery && (
<button
@@ -77,9 +76,8 @@ export function Controls({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={resetToNow}
className='h-9 rounded-[11px] hover:bg-secondary'
className='h-9 w-9 p-0 hover:bg-secondary'
disabled={isRefetching}
>
{isRefetching ? (
@@ -97,9 +95,8 @@ export function Controls({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onExport}
className='h-9 rounded-[11px] hover:bg-secondary'
className='h-9 w-9 p-0 hover:bg-secondary'
aria-label='Export CSV'
>
<svg
@@ -123,7 +120,6 @@ export function Controls({
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setLive((v) => !v)}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
@@ -140,7 +136,6 @@ export function Controls({
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('logs')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
@@ -154,7 +149,6 @@ export function Controls({
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('dashboard')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',

View File

@@ -9,25 +9,25 @@ export interface AggregateMetrics {
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>

View File

@@ -71,6 +71,12 @@ export function WorkflowDetails({
}) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
// Check if any logs have pending status to show Resume column
const hasPendingExecutions = useMemo(() => {
return details?.logs?.some((log) => log.hasPendingPause === true) || false
}, [details])
const workflowColor = useMemo(
() => workflows[expandedWorkflowId]?.color || '#3972F6',
[workflows, expandedWorkflowId]
@@ -136,15 +142,15 @@ export function WorkflowDetails({
</button>
</div>
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
</div>
@@ -172,7 +178,7 @@ export function WorkflowDetails({
})
: 'Selected segment'
return (
<div className='mb-4 flex items-center justify-between rounded-[10px] border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='mb-4 flex items-center justify-between border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='flex items-center gap-2'>
<div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' />
<span className='font-medium'>
@@ -264,8 +270,15 @@ export function WorkflowDetails({
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='w-full overflow-x-auto'>
<div>
<div className='border-border border-b'>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div className='border-b-0'>
<div
className={cn(
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
@@ -287,9 +300,11 @@ export function WorkflowDetails({
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Duration
</div>
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Resume
</div>
{hasPendingExecutions && (
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Resume
</div>
)}
</div>
</div>
</div>
@@ -333,14 +348,21 @@ export function WorkflowDetails({
<div
key={log.id}
className={cn(
'cursor-pointer border-border border-b transition-all duration-200',
'cursor-pointer transition-all duration-200',
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
)}
onClick={() =>
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
}
>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div
className={cn(
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div>
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
@@ -356,34 +378,40 @@ export function WorkflowDetails({
</div>
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
isError
? 'bg-red-500 text-white'
: isPending
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
: 'bg-secondary text-card-foreground'
)}
>
{statusLabel}
</div>
{isError || !isPending ? (
<div
className={cn(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
>
{statusLabel}
</span>
</div>
) : (
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
{statusLabel}
</div>
)}
</div>
<div>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
style={{ backgroundColor: getTriggerColor(log.trigger) }}
>
{log.trigger}
</div>
@@ -403,7 +431,7 @@ export function WorkflowDetails({
{log.workflowName ? (
<div className='inline-flex items-center gap-2'>
<span
className='h-3.5 w-3.5 rounded'
className='h-3.5 w-3.5'
style={{ backgroundColor: log.workflowColor || '#64748b' }}
/>
<span
@@ -437,23 +465,25 @@ export function WorkflowDetails({
</div>
</div>
<div className='flex justify-end'>
{isPending && log.executionId ? (
<Link
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center rounded-md border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
</Link>
) : (
<span className='h-7 w-7' />
)}
</div>
{hasPendingExecutions && (
<div className='flex justify-end'>
{isPending && log.executionId ? (
<Link
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
</Link>
) : (
<span className='h-7 w-7' />
)}
</div>
)}
</div>
{isExpanded && (
<div className='px-2 pt-0 pb-4'>
<div className='rounded-md border bg-muted/30 p-2'>
<div className='border bg-muted/30 p-2'>
<pre className='max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs'>
{log.level === 'error' && errorStr ? errorStr : outputsStr}
</pre>

View File

@@ -59,7 +59,7 @@ export function WorkflowsList({
}
return (
<div
className='overflow-hidden rounded-lg border bg-card shadow-sm'
className='overflow-hidden border bg-card shadow-sm'
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
@@ -89,7 +89,7 @@ export function WorkflowsList({
return (
<div
key={workflow.workflowId}
className={`flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1.5 transition-colors ${
className={`flex cursor-pointer items-center gap-4 px-2 py-1.5 transition-colors ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
}`}
onClick={() => onToggleWorkflow(workflow.workflowId)}
@@ -97,7 +97,7 @@ export function WorkflowsList({
<div className='w-52 min-w-0 flex-shrink-0'>
<div className='flex items-center gap-2'>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded'
className='h-[14px] w-[14px] flex-shrink-0'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -35,9 +35,8 @@ interface FolderOption {
}
export default function FolderFilter() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore()
const { getFolderTree, fetchFolders } = useFolderStore()
const params = useParams()
const workspaceId = params.workspaceId as string
const [folders, setFolders] = useState<FolderOption[]>([])
@@ -111,7 +110,7 @@ export default function FolderFilter() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{loading ? 'Loading folders...' : getSelectedFoldersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,5 +1,5 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,8 +28,7 @@ export default function Level() {
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
{getDisplayLabel()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
@@ -37,7 +36,7 @@ export default function Level() {
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<DropdownMenuItem
key='all'
@@ -45,7 +44,7 @@ export default function Level() {
e.preventDefault()
setLevel('all')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>Any status</span>
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
@@ -60,7 +59,7 @@ export default function Level() {
e.preventDefault()
setLevel(levelItem.value)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />

View File

@@ -1,5 +1,5 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,7 +37,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
@@ -58,7 +58,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
onSelect={() => {
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
@@ -72,7 +72,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
onSelect={() => {
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -26,7 +26,6 @@ import type { TriggerType } from '@/stores/logs/filters/types'
export default function Trigger() {
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
const [search, setSearch] = useState('')
const triggerRef = useRef<HTMLButtonElement | null>(null)
const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [
{ value: 'manual', label: 'Manual', color: 'bg-gray-500' },
{ value: 'api', label: 'API', color: 'bg-blue-500' },
@@ -58,7 +57,7 @@ export default function Trigger() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -33,7 +33,6 @@ interface WorkflowOption {
}
export default function Workflow() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
@@ -91,7 +90,7 @@ export default function Workflow() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,7 @@
'use client'
import { TimerOff } from 'lucide-react'
import { Button } from '@/components/ui'
import { Button } from '@/components/emcn'
import { isProd } from '@/lib/environment'
import {
FilterSection,
@@ -33,7 +33,7 @@ export function Filters() {
<div className='h-full w-60 overflow-auto border-r p-4'>
{/* Show retention policy for free users in production only */}
{!isLoading && !isPaid && isProd && (
<div className='mb-4 overflow-hidden rounded-md border border-border'>
<div className='mb-4 overflow-hidden border border-border'>
<div className='flex items-center gap-2 border-b bg-background p-3'>
<TimerOff className='h-4 w-4 text-muted-foreground' />
<span className='font-medium text-sm'>Log Retention Policy</span>
@@ -44,9 +44,8 @@ export function Filters() {
</p>
<div className='mt-2.5'>
<Button
size='sm'
variant='secondary'
className='h-8 w-full px-3 py-1.5 text-xs'
variant='default'
className='h-8 w-full px-3 text-xs'
onClick={handleUpgradeClick}
>
Upgrade Plan

View File

@@ -2,8 +2,8 @@
import { useState } from 'react'
import { Maximize2, Minimize2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
@@ -37,7 +37,7 @@ export function FrozenCanvasModal({
className={cn(
'flex flex-col gap-0 p-0',
isFullscreen
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] rounded-none'
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
)}
hideCloseButton={true}
@@ -68,19 +68,14 @@ export function FrozenCanvasModal({
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={toggleFullscreen}
className='h-[32px] w-[32px] p-0'
>
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
{isFullscreen ? (
<Minimize2 className='h-[14px] w-[14px]' />
) : (
<Maximize2 className='h-[14px] w-[14px]' />
)}
</Button>
<Button variant='ghost' size='sm' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>

View File

@@ -42,7 +42,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
title='Expand in modal'
>
<Maximize2 className='h-[12px] w-[12px]' />
@@ -62,7 +62,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
</div>
<div
className={cn(
'overflow-y-auto rounded-[8px] bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
@@ -75,14 +75,14 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
{/* Modal for large data */}
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
<X className='h-[14px] w-[14px]' />
</button>
@@ -194,7 +194,7 @@ function PinnedLogs({
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
@@ -217,7 +217,7 @@ function PinnedLogs({
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='rounded-[8px] bg-[var(--surface-5)] p-[16px] text-center'>
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>

View File

@@ -2,8 +2,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Search, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { parseQuery } from '@/lib/logs/query-parser'
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
@@ -131,7 +131,7 @@ export function AutocompleteSearch({
{/* Search Input */}
<div
className={cn(
'relative flex items-center gap-2 rounded-lg border bg-background pr-2 pl-3 transition-all duration-200',
'relative flex items-center gap-2 border bg-background pr-2 pl-3 transition-all duration-200',
'h-9 w-full min-w-[600px] max-w-[800px]',
state.isOpen && 'ring-1 ring-ring'
)}
@@ -190,7 +190,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-muted/50'
onMouseDown={(e) => {
e.preventDefault()
@@ -206,7 +205,7 @@ export function AutocompleteSearch({
{state.isOpen && state.suggestions.length > 0 && (
<div
ref={dropdownRef}
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-md'
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden border bg-popover shadow-md'
id={listboxId}
role='listbox'
aria-labelledby={inputId}
@@ -284,7 +283,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
onClick={() => removeFilter(filter)}
>
@@ -296,7 +294,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 text-muted-foreground text-xs hover:text-foreground'
onMouseDown={(e) => {
e.preventDefault()

View File

@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { ArrowDown, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
@@ -96,7 +96,6 @@ export function FileDownload({
return (
<Button
variant='ghost'
size='sm'
className={`h-7 px-2 text-xs ${className}`}
onClick={handleDownload}
disabled={isDownloading}
@@ -104,7 +103,7 @@ export function FileDownload({
{isDownloading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Download className='h-3 w-3' />
<ArrowDown className='h-[14px] w-[14px]' />
)}
{isDownloading ? 'Downloading...' : 'Download'}
</Button>

View File

@@ -2,8 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { Button, Tooltip } from '@/components/emcn'
import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
@@ -15,6 +18,7 @@ import { TraceSpans } from '@/app/workspace/[workspaceId]/logs/components/trace-
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import '@/components/emcn/components/code/code.css'
interface LogSidebarProps {
log: WorkflowLog | null
@@ -72,12 +76,17 @@ const formatJsonContent = (content: string, blockInput?: Record<string, any>): R
const { isJson, formatted } = tryPrettifyJson(content)
return (
<div className='group relative w-full rounded-md bg-secondary/30 p-3'>
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<CopyButton text={formatted} className='z-10 h-7 w-7' />
{isJson ? (
<pre className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{formatted}
</pre>
<div className='code-editor-theme'>
<pre
className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(formatted, languages.json, 'json'),
}}
/>
</div>
) : (
<LogMarkdownRenderer content={formatted} />
)}
@@ -123,7 +132,7 @@ const BlockContentDisplay = ({
<div className='mb-2 flex space-x-1'>
<button
onClick={() => setActiveTab('output')}
className={`rounded-md px-3 py-1 text-xs transition-colors ${
className={`px-3 py-1 text-xs transition-colors ${
activeTab === 'output'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50'
@@ -133,7 +142,7 @@ const BlockContentDisplay = ({
</button>
<button
onClick={() => setActiveTab('input')}
className={`rounded-md px-3 py-1 text-xs transition-colors ${
className={`px-3 py-1 text-xs transition-colors ${
activeTab === 'input'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50'
@@ -145,14 +154,19 @@ const BlockContentDisplay = ({
)}
{/* Content based on active tab */}
<div className='group relative rounded-md bg-secondary/30 p-3'>
<div className='group relative rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
{activeTab === 'output' ? (
<>
<CopyButton text={outputString} className='z-10 h-7 w-7' />
{isJson ? (
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{outputString}
</pre>
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(outputString, languages.json, 'json'),
}}
/>
</div>
) : (
<LogMarkdownRenderer content={outputString} />
)}
@@ -160,9 +174,14 @@ const BlockContentDisplay = ({
) : blockInputString ? (
<>
<CopyButton text={blockInputString} className='z-10 h-7 w-7' />
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{blockInputString}
</pre>
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(blockInputString, languages.json, 'json'),
}}
/>
</div>
</>
) : null}
</div>
@@ -323,8 +342,8 @@ export function Sidebar({
return (
<div
className={`fixed top-[96px] right-[16px] bottom-[16px] z-50 flex transform flex-col rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
className={`fixed top-[94px] right-0 bottom-0 z-50 flex transform flex-col overflow-hidden border-l bg-[var(--surface-1)] dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'}`}
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
aria-label='Log details sidebar'
@@ -340,16 +359,15 @@ export function Sidebar({
{log && (
<>
{/* Header */}
<div className='flex items-center justify-between px-[12px] pt-[12px] pb-[4px]'>
<div className='flex items-center justify-between px-[8px] pt-[14px] pb-[14px]'>
<h2 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Log Details
</h2>
<div className='flex items-center gap-[8px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
@@ -364,7 +382,6 @@ export function Sidebar({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
@@ -378,7 +395,6 @@ export function Sidebar({
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={onClose}
aria-label='Close'
@@ -389,7 +405,7 @@ export function Sidebar({
</div>
{/* Content */}
<div className='flex-1 overflow-hidden px-[12px]'>
<div className='flex-1 overflow-hidden px-[8px]'>
<ScrollArea className='h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='w-full space-y-[16px] pr-[12px] pb-[16px]'>
{/* Timestamp */}
@@ -409,22 +425,15 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Workflow
</h3>
<div
className='group relative text-[13px]'
style={{
color: log.workflow.color,
}}
>
<div className='group relative text-[13px]'>
<CopyButton text={log.workflow.name} />
<div
className='inline-flex items-center rounded-[8px] px-[8px] py-[4px] text-[12px]'
<span
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
{log.workflow.name}
</div>
</span>
</div>
</div>
)}
@@ -506,7 +515,7 @@ export function Sidebar({
{log.files.map((file, index) => (
<div
key={file.id || index}
className='flex items-center justify-between rounded-[8px] border bg-muted/30 p-[8px] dark:border-[var(--border)]'
className='flex items-center justify-between border bg-muted/30 p-[8px] dark:border-[var(--border)]'
>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-[13px]' title={file.name}>
@@ -534,9 +543,8 @@ export function Sidebar({
</h3>
<Button
variant='ghost'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-[8px] rounded-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
className='h-8 w-full justify-start gap-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
>
<Eye className='h-[14px] w-[14px]' />
View Snapshot
@@ -568,7 +576,7 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Tool Calls
</h3>
<div className='w-full overflow-x-hidden rounded-[8px] bg-secondary/30 p-[12px]'>
<div className='w-full overflow-x-hidden bg-secondary/30 p-[12px]'>
<ToolCallsDisplay metadata={log.executionData} />
</div>
</div>
@@ -580,7 +588,7 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Cost Breakdown
</h3>
<div className='overflow-hidden rounded-[8px] border dark:border-[var(--border)]'>
<div className='overflow-hidden border dark:border-[var(--border)]'>
<div className='space-y-[8px] p-[12px]'>
<div className='flex items-center justify-between'>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>

View File

@@ -1,5 +1,7 @@
import type React from 'react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-json'
import { transformBlockData } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/utils'
import '@/components/emcn/components/code/code.css'
export function BlockDataDisplay({
data,
@@ -14,66 +16,11 @@ export function BlockDataDisplay({
}) {
if (!data) return null
const renderValue = (value: unknown, key?: string): React.ReactNode => {
if (value === null) return <span className='text-muted-foreground italic'>null</span>
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return (
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
)
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-0.5'>
<span className='text-muted-foreground'>[</span>
<div className='ml-2 space-y-0.5'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
{index}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
</div>
<span className='text-muted-foreground'>]</span>
</div>
)
}
if (typeof value === 'object') {
const entries = Object.entries(value)
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-0.5'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
</div>
))}
</div>
)
}
return <span>{String(value)}</span>
}
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
const dataToDisplay = transformedData || data
// Format the data as JSON string
const jsonString = JSON.stringify(dataToDisplay, null, 2)
if (isError && typeof data === 'object' && data !== null && 'error' in data) {
const errorData = data as { error: string; [key: string]: unknown }
@@ -86,15 +33,25 @@ export function BlockDataDisplay({
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-0.5'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-1.5'>
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(
JSON.stringify(
Object.fromEntries(
Object.entries(transformedData).filter(
([key]) => key !== 'error' && key !== 'success'
)
),
null,
2
),
languages.json,
'json'
),
}}
/>
</div>
)}
</div>
@@ -102,6 +59,13 @@ export function BlockDataDisplay({
}
return (
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
<div className='code-editor-theme overflow-hidden'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(jsonString, languages.json, 'json'),
}}
/>
</div>
)
}

View File

@@ -34,7 +34,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
Input
</button>
{inputExpanded && (
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
</div>
)}
@@ -55,7 +55,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
{span.status === 'error' ? 'Error Details' : 'Output'}
</button>
{outputExpanded && (
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<BlockDataDisplay
data={span.output}
blockType={span.type}

View File

@@ -610,7 +610,7 @@ export function TraceSpanItem({
})()}
{localHoveredPercent != null && (
<div
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-gray-600'
style={{
left: `${Math.max(0, Math.min(100, localHoveredPercent))}%`,
zIndex: 12,

View File

@@ -215,10 +215,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
})()}
</div>
</div>
<div
ref={containerRef}
className='relative w-full overflow-hidden rounded-md border shadow-sm'
>
<div ref={containerRef} className='relative w-full overflow-hidden border shadow-sm'>
{filtered.map((span, index) => {
const normalizedSpan = normalizeChildWorkflowSpan(span)
const hasSubItems = Boolean(

View File

@@ -810,17 +810,17 @@ export default function Dashboard() {
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>Filters:</span>
{workflowIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''}
</span>
)}
{folderIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{folderIds.length} folder{folderIds.length !== 1 ? 's' : ''}
</span>
)}
{triggers.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{triggers.length} trigger{triggers.length !== 1 ? 's' : ''}
</span>
)}

View File

@@ -809,18 +809,33 @@ export default function Logs() {
{/* Status */}
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
isError
? 'bg-red-500 text-white'
: isPending
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
: 'bg-secondary text-card-foreground'
)}
>
{statusLabel}
</div>
{isError || !isPending ? (
<div
className={cn(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
>
{statusLabel}
</span>
</div>
) : (
<div className='inline-flex items-center bg-amber-300 px-[8px] py-[2px] font-medium text-[12px] text-amber-900 dark:bg-amber-500/90 dark:text-black'>
{statusLabel}
</div>
)}
</div>
{/* Workflow */}
@@ -843,17 +858,8 @@ export default function Logs() {
<div className='hidden xl:block'>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
className='inline-flex items-center rounded-[6px] px-[8px] py-[2px] font-medium text-[12px] text-white'
style={{ backgroundColor: getTriggerColor(log.trigger) }}
>
{log.trigger}
</div>

View File

@@ -1,12 +1,7 @@
import { db } from '@sim/db'
import { templateCreators, templateStars, templates } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { notFound } from 'next/navigation'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
const logger = createLogger('TemplatePage')
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import TemplateDetails from '@/app/templates/[id]/template'
interface TemplatePageProps {
params: Promise<{
@@ -15,91 +10,25 @@ interface TemplatePageProps {
}>
}
/**
* Workspace-scoped template detail page.
* Requires authentication and workspace membership to access.
* Uses the shared TemplateDetails component with workspace context.
*/
export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
const { workspaceId } = await params
const session = await getSession()
try {
if (!id || typeof id !== 'string' || id.length !== 36) {
notFound()
}
const session = await getSession()
const templateData = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (templateData.length === 0) {
notFound()
}
const { template, creator } = templateData[0]
if (!session?.user?.id && template.status !== 'approved') {
notFound()
}
if (!template.id || !template.name) {
logger.error('Template missing required fields:', {
id: template.id,
name: template.name,
})
notFound()
}
let isStarred = false
if (session?.user?.id) {
try {
const starData = await db
.select({ id: templateStars.id })
.from(templateStars)
.where(
and(
eq(templateStars.templateId, template.id),
eq(templateStars.userId, session.user.id)
)
)
.limit(1)
isStarred = starData.length > 0
} catch {
isStarred = false
}
}
const serializedTemplate = {
...template,
creator: creator || null,
createdAt: template.createdAt.toISOString(),
updatedAt: template.updatedAt.toISOString(),
isStarred,
}
return (
<TemplateDetails
template={JSON.parse(JSON.stringify(serializedTemplate))}
workspaceId={workspaceId}
currentUserId={session?.user?.id || null}
/>
)
} catch (error) {
logger.error('Error loading template:', error)
return (
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<h1 className='mb-[14px] font-medium text-[18px]'>Error Loading Template</h1>
<p className='text-[#888888] text-[14px]'>There was an error loading this template.</p>
<p className='mt-[10px] text-[#888888] text-[12px]'>Template ID: {id}</p>
<p className='mt-[10px] text-[12px] text-red-500'>
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
)
// Require authentication
if (!session?.user?.id) {
redirect('/login')
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <TemplateDetails isWorkspaceContext={true} />
}

View File

@@ -1,423 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import {
ArrowLeft,
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDetails')
interface TemplateDetailsProps {
template: Template
workspaceId: string
currentUserId: string | null
}
// Icon mapping - reuse from template-card
const iconMap = {
FileText,
NotebookPen,
BookOpen,
Edit,
BarChart3,
LineChart,
TrendingUp,
Target,
Database,
Server,
Cloud,
Folder,
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
HeadphonesIcon,
Users,
Settings,
Wrench,
Bot,
Brain,
Cpu,
Code,
Zap,
Workflow,
Search,
Play,
Layers,
Lightbulb,
Globe,
Award,
}
/**
* Get icon component from template icon name
*/
const getIconComponent = (icon: string): React.ReactNode => {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? (
<IconComponent className='h-[14px] w-[14px]' />
) : (
<FileText className='h-[14px] w-[14px]' />
)
}
export default function TemplateDetails({
template,
workspaceId,
currentUserId,
}: TemplateDetailsProps) {
const router = useRouter()
const searchParams = useSearchParams()
// Initialize all state hooks first (hooks must be called unconditionally)
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
const [starCount, setStarCount] = useState(template?.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isOwner = currentUserId && template?.userId === currentUserId
// Auto-use template after login if use=true query param is present
useEffect(() => {
if (!template?.id) return
const shouldAutoUse = searchParams?.get('use') === 'true'
if (shouldAutoUse && currentUserId && !isUsing) {
handleUseTemplate()
// Clean up URL
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
}
}, [searchParams, currentUserId, template?.id])
// Defensive check for template AFTER initializing hooks
if (!template) {
logger.error('Template prop is undefined or null in TemplateDetails component', {
template,
workspaceId,
currentUserId,
})
return (
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
<p className='text-[#888888] text-[14px]'>
The template you're looking for doesn't exist.
</p>
<p className='mt-[10px] text-[#888888] text-[12px]'>Template data failed to load</p>
</div>
</div>
)
}
logger.info('Template loaded in TemplateDetails', {
id: template.id,
name: template.name,
hasState: !!template.state,
})
/**
* Render workflow preview with consistent error handling
*/
const renderWorkflowPreview = () => {
// Follow the same pattern as deployed-workflow-card.tsx
if (!template?.state) {
logger.info('Template has no state:', template)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-[#888888]'>
<div className='mb-[10px] font-medium text-[14px]'> No Workflow Data</div>
<div className='text-[12px]'>This template doesn't contain workflow state data.</div>
</div>
</div>
)
}
logger.info('Template state:', template.state)
logger.info('Template state type:', typeof template.state)
logger.info('Template state blocks:', template.state.blocks)
logger.info('Template state edges:', template.state.edges)
try {
return (
<WorkflowPreview
workflowState={template.state as WorkflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
)
} catch (error) {
logger.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-[#888888]'>
<div className='mb-[10px] font-medium text-[14px]'>⚠️ Preview Error</div>
<div className='text-[12px]'>Unable to render workflow preview</div>
</div>
</div>
)
}
}
const handleBack = () => {
router.back()
}
const handleStarToggle = async () => {
if (isStarring || !currentUserId) return
setIsStarring(true)
try {
const method = isStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${template.id}/star`, { method })
if (response.ok) {
setIsStarred(!isStarred)
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
}
} catch (error) {
logger.error('Error toggling star:', error)
} finally {
setIsStarring(false)
}
}
const handleUseTemplate = async () => {
if (isUsing) return
// Check if user is logged in
if (!currentUserId) {
// Redirect to login with callback URL to use template after login
const callbackUrl = encodeURIComponent(
`/workspace/${workspaceId}/templates/${template.id}?use=true`
)
router.push(`/login?callbackUrl=${callbackUrl}`)
return
}
setIsUsing(true)
try {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
throw new Error('Failed to use template')
}
const { workflowId } = await response.json()
// Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error using template:', error)
} finally {
setIsUsing(false)
}
}
const handleEditTemplate = async () => {
if (isEditing || !currentUserId) return
setIsEditing(true)
try {
// If template already has a connected workflowId, check if it exists in user's workspace
if (template.workflowId) {
// Try to fetch the workflow to see if it still exists
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.ok) {
// Workflow exists, redirect to it
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
return
}
}
// No connected workflow or it was deleted - create a new one
const response = await fetch(`/api/templates/${template.id}/edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
throw new Error('Failed to edit template')
}
const { workflowId } = await response.json()
// Navigate to the workflow
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error editing template:', error)
} finally {
setIsEditing(false)
}
}
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-[14px] flex items-center gap-[8px] text-[#888888] transition-colors hover:text-white'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>Go back</span>
</button>
{/* Header */}
<div>
<div className='flex items-start gap-[12px]'>
{/* Icon */}
<div
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
style={{ backgroundColor: template.color }}
>
{getIconComponent(template.icon)}
</div>
<h1 className='font-medium text-[18px]'>{template.name}</h1>
</div>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>{template.description}</p>
</div>
{/* Stats and Actions */}
<div className='mt-[14px] flex items-center justify-between'>
{/* Stats */}
<div className='flex items-center gap-[12px] font-medium text-[#888888] text-[12px]'>
<div className='flex items-center gap-[6px]'>
<Eye className='h-[12px] w-[12px]' />
<span>{template.views} views</span>
</div>
<div className='flex items-center gap-[6px]'>
<Star className='h-[12px] w-[12px]' />
<span>{starCount} stars</span>
</div>
<div className='flex items-center gap-[6px]'>
<User className='h-[12px] w-[12px]' />
<span>by {template.author}</span>
</div>
{template.authorType === 'organization' && (
<div className='flex items-center gap-[6px]'>
<Users className='h-[12px] w-[12px]' />
<span>Organization</span>
</div>
)}
</div>
{/* Action buttons */}
<div className='flex items-center gap-[8px]'>
{/* Star button - only for logged-in users */}
{currentUserId && (
<Button
variant={isStarred ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={handleStarToggle}
disabled={isStarring}
>
<Star className={cn('mr-[6px] h-[14px] w-[14px]', isStarred && 'fill-current')} />
<span className='font-medium text-[12px]'>{starCount}</span>
</Button>
)}
{/* Edit button - only for template owner when logged in */}
{isOwner && currentUserId && (
<Button
variant='default'
className='h-[32px] rounded-[6px]'
onClick={handleEditTemplate}
disabled={isEditing}
>
<Edit className='mr-[6px] h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>{isEditing ? 'Opening...' : 'Edit'}</span>
</Button>
)}
{/* Use template button */}
<Button
variant='active'
className='h-[32px] rounded-[6px]'
onClick={handleUseTemplate}
disabled={isUsing}
>
<span className='font-medium text-[12px]'>
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{/* Workflow preview */}
<div className='mt-[24px] flex-1'>
<h2 className='mb-[14px] font-medium text-[14px]'>Workflow Preview</h2>
<div className='h-[calc(100vh-280px)] w-full overflow-hidden rounded-[8px] bg-[#202020]'>
{renderWorkflowPreview()}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
@@ -14,6 +14,7 @@ interface TemplateCardProps {
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
@@ -138,11 +139,12 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
return normalized
}
export function TemplateCard({
function TemplateCardInner({
id,
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
icon,
@@ -164,11 +166,38 @@ export function TemplateCard({
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
if (!previewRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
observer.disconnect()
}
},
{ root: null, rootMargin: '200px', threshold: 0 }
)
observer.observe(previewRef.current)
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort(),
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
@@ -227,35 +256,42 @@ export function TemplateCard({
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const getTemplateUrl = () => {
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
return `/workspace/${workspaceId}/templates/${id}`
}
return `/templates/${id}`
}
}, [params?.workspaceId, id])
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
router.push(getTemplateUrl())
}
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
router.push(getTemplateUrl())
}
router.push(templateUrl)
},
[router, templateUrl]
)
return (
<div
@@ -263,10 +299,13 @@ export function TemplateCard({
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div className='h-[180px] w-full overflow-hidden rounded-[6px]'>
{normalizeWorkflowState(state) ? (
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
workflowState={normalizeWorkflowState(state)!}
workflowState={normalizedState}
showSubBlocks={false}
height={180}
width='100%'
@@ -341,7 +380,15 @@ export function TemplateCard({
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] flex-shrink-0 rounded-full bg-[#4A4A4A]' />
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[18px] w-[18px] text-[#888888]' />
</div>
)}
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
@@ -353,7 +400,7 @@ export function TemplateCard({
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-400 text-yellow-400' : 'text-[#888888]',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
@@ -363,3 +410,5 @@ export function TemplateCard({
</div>
)
}
export const TemplateCard = memo(TemplateCardInner)

View File

@@ -1,46 +1,60 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
interface TemplatesPageProps {
params: Promise<{
workspaceId: string
}>
}
/**
* Workspace-scoped Templates page.
*
* Mirrors the global templates data loading while rendering the workspace
* templates UI (which accounts for the sidebar layout). This avoids redirecting
* to the global /templates route and keeps users within their workspace context.
* Requires authentication and workspace membership to access.
*/
export default async function TemplatesPage() {
export default async function TemplatesPage({ params }: TemplatesPageProps) {
const { workspaceId } = await params
const session = await getSession()
// Determine effective super user (DB flag AND UI mode enabled)
let effectiveSuperUser = false
if (session?.user?.id) {
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userSettings = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
effectiveSuperUser = isSuperUser && superUserModeEnabled
// Require authentication
if (!session?.user?.id) {
redirect('/login')
}
// Load templates (same logic as global page)
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
// Determine effective super user (DB flag AND UI mode enabled)
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userSettings = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
const effectiveSuperUser = isSuperUser && superUserModeEnabled
// Load templates from database
let rows:
| Array<{
id: string
workflowId: string | null
name: string
details?: any
details?: unknown
creatorId: string | null
creator: {
id: string
@@ -124,24 +138,46 @@ export default async function TemplatesPage() {
row.creator?.referenceType === 'user' ? row.creator.referenceId : '' /* no owner context */
return {
// New structure fields
id: row.id,
workflowId: row.workflowId,
userId,
name: row.name,
description: row.details?.tagline ?? null,
author: row.creator?.name ?? 'Unknown',
authorType,
organizationId,
details: row.details as { tagline?: string; about?: string } | null,
creatorId: row.creatorId,
creator: row.creator
? {
id: row.creator.id,
name: row.creator.name,
profileImageUrl: row.creator.profileImageUrl,
details: row.creator.details as {
about?: string
xUrl?: string
linkedinUrl?: string
websiteUrl?: string
contactEmail?: string
} | null,
referenceType: row.creator.referenceType,
referenceId: row.creator.referenceId,
}
: null,
views: row.views,
stars: row.stars,
color: '#3972F6', // default color for workspace cards
icon: 'Workflow', // default icon for workspace cards
status: row.status,
tags: row.tags,
requiredCredentials: row.requiredCredentials,
state: row.state as WorkspaceTemplate['state'],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isStarred: row.isStarred ?? false,
isSuperUser: effectiveSuperUser,
// Legacy fields for backward compatibility
userId,
description: (row.details as any)?.tagline ?? null,
author: row.creator?.name ?? 'Unknown',
authorType,
organizationId,
color: '#3972F6', // default color for workspace cards
icon: 'Workflow', // default icon for workspace cards
}
}) ?? []

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Layout, Search } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Input } from '@/components/ui/input'
@@ -10,29 +10,48 @@ import {
TemplateCardSkeleton,
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
const logger = createLogger('TemplatesPage')
// Template data structure
/**
* Template data structure with support for both new and legacy fields
*/
export interface Template {
id: string
workflowId: string | null
userId: string
name: string
description: string | null
author: string
authorType: 'user' | 'organization'
organizationId: string | null
details?: {
tagline?: string
about?: string
} | null
creatorId: string | null
creator?: {
id: string
name: string
profileImageUrl?: string | null
details?: CreatorProfileDetails | null
referenceType: 'user' | 'organization'
referenceId: string
} | null
views: number
stars: number
color: string
icon: string
status: 'pending' | 'approved' | 'rejected'
tags: string[]
requiredCredentials: unknown
state: WorkflowState
createdAt: Date | string
updatedAt: Date | string
isStarred: boolean
isSuperUser?: boolean
// Legacy fields for backward compatibility with existing UI
userId?: string
description?: string | null
author?: string
authorType?: 'user' | 'organization'
organizationId?: string | null
color?: string
icon?: string
}
interface TemplatesProps {
@@ -41,21 +60,23 @@ interface TemplatesProps {
isSuperUser: boolean
}
/**
* Templates list component displaying workflow templates
* Supports filtering by tab (gallery/your/pending) and search
*/
export default function Templates({
initialTemplates,
currentUserId,
isSuperUser,
}: TemplatesProps) {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('your')
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
}
// Handle star change callback from template card
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
@@ -64,98 +85,76 @@ export default function Templates({
)
}
// Get templates for the active tab with search filtering
const getActiveTabTemplates = () => {
let filtered = templates
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
// Filter by active tab
if (activeTab === 'your') {
filtered = filtered.filter(
(template) => template.userId === currentUserId || template.isStarred === true
)
} else if (activeTab === 'gallery') {
// Show all approved templates
filtered = filtered.filter((template) => template.status === 'approved')
} else if (activeTab === 'pending') {
// Show pending templates for super users
filtered = filtered.filter((template) => template.status === 'pending')
}
return templates.filter((template) => {
// Filter by tab
const tabMatch =
activeTab === 'your'
? template.userId === currentUserId || template.isStarred
: activeTab === 'gallery'
? template.status === 'approved'
: template.status === 'pending'
// Apply search filter
if (!tabMatch) return false
// Filter by search query
if (!query) return true
const searchableText = [
template.name,
template.description,
template.details?.tagline,
template.author,
template.creator?.name,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery, currentUserId])
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
filtered = filtered.filter(
(template) =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
)
return {
title: 'No templates found',
description: 'Try a different search term',
}
}
return filtered
}
const messages = {
pending: {
title: 'No pending templates',
description: 'New submissions will appear here',
},
your: {
title: 'No templates yet',
description: 'Create or star templates to see them here',
},
gallery: {
title: 'No templates available',
description: 'Templates will appear once approved',
},
}
const activeTemplates = getActiveTabTemplates()
// Helper function to render template cards
const renderTemplateCard = (template: Template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.description || ''}
author={template.author}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
/>
)
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
}
// Calculate counts for tabs
const yourTemplatesCount = templates.filter(
(template) => template.userId === currentUserId || template.isStarred === true
).length
const galleryCount = templates.filter((template) => template.status === 'approved').length
const pendingCount = templates.filter((template) => template.status === 'pending').length
const navigationTabs = [
{
id: 'gallery',
label: 'Gallery',
count: galleryCount,
},
{
id: 'your',
label: 'Your Templates',
count: yourTemplatesCount,
},
...(isSuperUser
? [
{
id: 'pending',
label: 'Pending',
count: pendingCount,
},
]
: []),
]
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Header */}
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
@@ -168,7 +167,6 @@ export default function Templates({
</p>
</div>
{/* Search and Badges */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
@@ -183,52 +181,67 @@ export default function Templates({
<Button
variant={activeTab === 'gallery' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => handleTabClick('gallery')}
onClick={() => setActiveTab('gallery')}
>
Gallery
</Button>
<Button
variant={activeTab === 'your' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => handleTabClick('your')}
onClick={() => setActiveTab('your')}
>
Your Templates
</Button>
{isSuperUser && (
<Button
variant={activeTab === 'pending' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => setActiveTab('pending')}
>
Pending
</Button>
)}
</div>
</div>
{/* Divider */}
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{/* Templates Grid - Based on Active Tab */}
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading ? (
renderSkeletonCards()
) : activeTemplates.length === 0 ? (
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'
: activeTab === 'pending'
? 'No pending templates'
: activeTab === 'your'
? 'No templates yet'
: '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'
: activeTab === 'your'
? 'Create or star templates to see them here'
: 'Templates will appear once approved'}
</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>
) : (
activeTemplates.map((template) => renderTemplateCard(template))
filteredTemplates.map((template) => {
const author = template.author || template.creator?.name || 'Unknown'
const authorImageUrl = template.creator?.profileImageUrl || null
return (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.description || template.details?.tagline || ''}
author={author}
authorImageUrl={authorImageUrl}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
/>
)
})
)}
</div>
</div>

View File

@@ -78,33 +78,66 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
})
// Fetch creator profiles
useEffect(() => {
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creator-profiles')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
setLoadingCreators(true)
try {
const response = await fetch('/api/creator-profiles')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
// Auto-select creator profile when there's only one option and no selection yet
useEffect(() => {
const currentCreatorId = form.getValues('creatorId')
if (creatorOptions.length === 1 && !currentCreatorId) {
form.setValue('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, form])
// Listen for creator profile saved event
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
// Refetch creator profiles (autoselection will happen via the effect above)
await fetchCreatorOptions()
// Close settings modal and reopen deploy modal to template tab
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
}, 100)
}
window.addEventListener('creator-profile-saved', handleCreatorProfileSaved)
return () => {
window.removeEventListener('creator-profile-saved', handleCreatorProfileSaved)
}
}, [])
// Check for existing template
useEffect(() => {
const checkExistingTemplate = async () => {
@@ -454,12 +487,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)}
{/* Template State Preview Dialog */}
{showPreviewDialog && (
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
<DialogHeader>
<DialogTitle>Template State Preview</DialogTitle>
</DialogHeader>
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
<DialogHeader>
<DialogTitle>Published Template Preview</DialogTitle>
</DialogHeader>
{showPreviewDialog && (
<div className='mt-4'>
{(() => {
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
@@ -487,7 +520,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
return (
<div className='h-[500px] w-full'>
<WorkflowPreview
key={`template-preview-${existingTemplate.id}-${Date.now()}`}
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
showSubBlocks={true}
height='100%'
@@ -497,9 +530,9 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)
})()}
</div>
</DialogContent>
</Dialog>
)}
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -445,6 +445,23 @@ export function DeployModal({
}
}, [open, selectedStreamingOutputs, setSelectedStreamingOutputs])
// Listen for event to reopen deploy modal
useEffect(() => {
const handleOpenDeployModal = (event: Event) => {
const customEvent = event as CustomEvent<{ tab?: TabView }>
onOpenChange(true)
if (customEvent.detail?.tab) {
setActiveTab(customEvent.detail.tab)
}
}
window.addEventListener('open-deploy-modal', handleOpenDeployModal)
return () => {
window.removeEventListener('open-deploy-modal', handleOpenDeployModal)
}
}, [onOpenChange])
const handleActivateVersion = (version: number) => {
setVersionToActivate(version)
setActiveTab('api')

View File

@@ -2,5 +2,4 @@ export { DeployModal } from './deploy-modal/deploy-modal'
export { DeploymentControls } from './deployment-controls/deployment-controls'
export { ExportControls } from './export-controls/export-controls'
export { TemplateModal } from './template-modal/template-modal'
export { UserAvatarStack } from './user-avatar-stack/user-avatar-stack'
export { WebhookSettings } from './webhook-settings/webhook-settings'

View File

@@ -1,83 +0,0 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
interface AvatarProps {
connectionId: string | number
name?: string
color?: string
avatarUrl?: string | null
tooltipContent?: React.ReactNode | null
size?: 'sm' | 'md' | 'lg'
index?: number // Position in stack for z-index
}
export function UserAvatar({
connectionId,
name,
color,
avatarUrl,
tooltipContent,
size = 'md',
index = 0,
}: AvatarProps) {
const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color])
const sizeClass = {
sm: 'h-5 w-5 text-[10px]',
md: 'h-7 w-7 text-xs',
lg: 'h-9 w-9 text-sm',
}[size]
const pixelSize = {
sm: 20,
md: 28,
lg: 36,
}[size]
const initials = name ? name.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(avatarUrl)
const avatarElement = (
<div
className={`
${sizeClass} relative flex flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full border-2 border-white font-semibold text-white shadow-sm `}
style={
{
background: hasAvatar ? undefined : gradient,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && avatarUrl ? (
<Image
src={avatarUrl}
alt={name ? `${name}'s avatar` : 'User avatar'}
fill
sizes={`${pixelSize}px`}
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={avatarUrl.startsWith('http')}
/>
) : (
initials
)}
</div>
)
if (tooltipContent) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom' className='max-w-xs'>
{tooltipContent}
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
}

View File

@@ -1,106 +0,0 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { UserAvatar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar'
import { usePresence } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence'
interface User {
connectionId: string | number
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UserAvatarStackProps {
users?: User[]
maxVisible?: number
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function UserAvatarStack({
users: propUsers,
maxVisible = 3,
size = 'md',
className = '',
}: UserAvatarStackProps) {
// Use presence data if no users are provided via props
const { users: presenceUsers } = usePresence()
const users = propUsers || presenceUsers
// Get operation error state from collaborative workflow
// Memoize the processed users to avoid unnecessary re-renders
const { visibleUsers, overflowCount } = useMemo(() => {
if (users.length === 0) {
return { visibleUsers: [], overflowCount: 0 }
}
const visible = users.slice(0, maxVisible)
const overflow = Math.max(0, users.length - maxVisible)
return {
visibleUsers: visible,
overflowCount: overflow,
}
}, [users, maxVisible])
// Determine spacing based on size
const spacingClass = {
sm: '-space-x-1',
md: '-space-x-1.5',
lg: '-space-x-2',
}[size]
const shouldShowAvatars = visibleUsers.length > 0
return (
<div className={`flex flex-col items-start ${className}`}>
{shouldShowAvatars && (
<div className={cn('flex items-center px-2 py-1', spacingClass)}>
{visibleUsers.map((user, index) => (
<UserAvatar
key={user.connectionId}
connectionId={user.connectionId}
name={user.name}
color={user.color}
avatarUrl={user.avatarUrl}
size={size}
index={index}
tooltipContent={
user.name ? (
<div className='text-center'>
<div className='font-medium'>{user.name}</div>
{user.info && (
<div className='mt-1 text-muted-foreground text-xs'>{user.info}</div>
)}
</div>
) : null
}
/>
))}
{overflowCount > 0 && (
<UserAvatar
connectionId='overflow-indicator'
name={`+${overflowCount}`}
size={size}
index={visibleUsers.length}
tooltipContent={
<div className='text-center'>
<div className='font-medium'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</div>
<div className='mt-1 text-muted-foreground text-xs'>
{users.length} total online
</div>
</div>
}
/>
)}
</div>
)}
</div>
)
}

View File

@@ -25,7 +25,12 @@ import type { UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChatHistory, useCopilotInitialization, useTodoManagement } from './hooks'
import {
useChatHistory,
useCopilotInitialization,
useLandingPrompt,
useTodoManagement,
} from './hooks'
const logger = createLogger('Copilot')
@@ -125,6 +130,22 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
setPlanTodos,
})
/**
* Helper function to focus the copilot input
*/
const focusInput = useCallback(() => {
userInputRef.current?.focus()
}, [])
// Handle landing page prompt retrieval and population
useLandingPrompt({
isInitialized,
setInputValue,
focusInput,
isSendingMessage,
currentInputValue: inputValue,
})
/**
* Auto-scroll to bottom when chat loads in
*/

View File

@@ -1,3 +1,4 @@
export { useChatHistory } from './use-chat-history'
export { useCopilotInitialization } from './use-copilot-initialization'
export { useLandingPrompt } from './use-landing-prompt'
export { useTodoManagement } from './use-todo-management'

View File

@@ -0,0 +1,77 @@
'use client'
import { useEffect, useRef } from 'react'
import { LandingPromptStorage } from '@/lib/browser-storage'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useLandingPrompt')
interface UseLandingPromptProps {
/**
* Whether the copilot is fully initialized and ready to receive input
*/
isInitialized: boolean
/**
* Callback to set the input value in the copilot
*/
setInputValue: (value: string) => void
/**
* Callback to focus the copilot input
*/
focusInput: () => void
/**
* Whether a message is currently being sent (prevents overwriting during active chat)
*/
isSendingMessage: boolean
/**
* Current input value (to avoid overwriting if user has already typed)
*/
currentInputValue: string
}
/**
* Custom hook to handle landing page prompt retrieval and population
*
* When a user enters a prompt on the landing page and signs up/logs in,
* this hook retrieves that prompt from localStorage and populates it
* in the copilot input once the copilot is initialized.
*
* @param props - Configuration for landing prompt handling
*/
export function useLandingPrompt(props: UseLandingPromptProps) {
const { isInitialized, setInputValue, focusInput, isSendingMessage, currentInputValue } = props
const hasCheckedRef = useRef(false)
useEffect(() => {
// Only check once when copilot is first initialized
if (!isInitialized || hasCheckedRef.current || isSendingMessage) {
return
}
// If user has already started typing, don't override
if (currentInputValue && currentInputValue.trim().length > 0) {
hasCheckedRef.current = true
return
}
// Try to retrieve the stored prompt (max age: 24 hours)
const prompt = LandingPromptStorage.consume()
if (prompt) {
logger.info('Retrieved landing page prompt, populating copilot input')
setInputValue(prompt)
// Focus the input after a brief delay to ensure DOM is ready
setTimeout(() => {
focusInput()
}, 150)
}
hasCheckedRef.current = true
}, [isInitialized, setInputValue, focusInput, isSendingMessage, currentInputValue])
}

View File

@@ -2,8 +2,9 @@
import { useCallback, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Rocket } from '@/components/emcn'
import { Button, Rocket, Tooltip } from '@/components/emcn'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChangeDetection, useDeployedState, useDeployment } from './hooks'
@@ -21,6 +22,7 @@ interface DeployProps {
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const { isLoading: isRegistryLoading } = useWorkflowRegistry()
const { hasBlocks } = useCurrentWorkflow()
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry((state) =>
@@ -49,8 +51,9 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
refetchDeployedState,
})
const isEmpty = !hasBlocks()
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy
const isDisabled = isDeploying || !canDeploy || isEmpty
const isPreviousVersionActive = isDeployed && changeDetected
/**
@@ -75,21 +78,50 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
}
}
/**
* Get tooltip text based on current state
*/
const getTooltipText = () => {
if (isEmpty) {
return 'Cannot deploy an empty workflow'
}
if (!canDeploy) {
return 'Admin permissions required'
}
if (isDeploying) {
return 'Deploying...'
}
if (changeDetected) {
return 'Update deployment'
}
if (isDeployed) {
return 'Active deployment'
}
return 'Deploy workflow'
}
return (
<>
<Button
className='h-[32px] gap-[8px] px-[10px]'
variant='active'
onClick={onDeployClick}
disabled={isDisabled}
>
{isDeploying ? (
<Loader2 className='h-[13px] w-[13px] animate-spin' />
) : (
<Rocket className='h-[13px] w-[13px]' />
)}
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>
<Button
className='h-[32px] gap-[8px] px-[10px]'
variant='active'
onClick={onDeployClick}
disabled={isDisabled}
>
{isDeploying ? (
<Loader2 className='h-[13px] w-[13px] animate-spin' />
) : (
<Rocket className='h-[13px] w-[13px]' />
)}
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content>{getTooltipText()}</Tooltip.Content>
</Tooltip.Root>
<DeployModal
open={isModalOpen}

View File

@@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
@@ -94,6 +95,7 @@ export function LongInput({
}: LongInputProps) {
// Local state for immediate UI updates during streaming
const [localContent, setLocalContent] = useState<string>('')
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
// Wand functionality - always call the hook unconditionally
const wandHook = useWand({
@@ -110,9 +112,22 @@ export function LongInput({
onGeneratedContent: (content) => {
// Final content update (fallback)
setLocalContent(content)
if (!isPreview && !disabled) {
persistSubBlockValueRef.current(content)
}
},
})
const [, setSubBlockValue] = useSubBlockValue<string>(blockId, subBlockId, false, {
isStreaming: wandHook.isStreaming,
})
useEffect(() => {
persistSubBlockValueRef.current = (value: string) => {
setSubBlockValue(value)
}
}, [setSubBlockValue])
// Check if wand is actually enabled
const isWandEnabled = config.wandConfig?.enabled ?? false

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
@@ -82,6 +83,7 @@ export function ShortInput({
const [localContent, setLocalContent] = useState<string>('')
const [isFocused, setIsFocused] = useState(false)
const [copied, setCopied] = useState(false)
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
// Always call the hook - hooks must be called unconditionally
const webhookManagement = useWebhookManagement({
@@ -105,9 +107,22 @@ export function ShortInput({
onGeneratedContent: (content) => {
// Final content update
setLocalContent(content)
if (!isPreview && !disabled && !readOnly) {
persistSubBlockValueRef.current(content)
}
},
})
const [, setSubBlockValue] = useSubBlockValue<string>(blockId, subBlockId, false, {
isStreaming: wandHook.isStreaming,
})
useEffect(() => {
persistSubBlockValueRef.current = (value: string) => {
setSubBlockValue(value)
}
}, [setSubBlockValue])
// Check if wand is actually enabled
const isWandEnabled = config.wandConfig?.enabled ?? false

View File

@@ -310,6 +310,47 @@ export function FieldFormat({
)
}
if (field.type === 'files') {
const lineCount = fieldValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className='font-medium font-mono text-[var(--text-muted)] text-xs'
style={{ height: `${21}px`, lineHeight: `${21}px` }}
>
{i + 1}
</div>
))
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{
'[\n {\n "data": "data:application/pdf;base64,...",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
)
}
return (
<>
<Input

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn/components/button/button'
import { Trash } from '@/components/emcn/icons/trash'
@@ -60,42 +60,57 @@ export function Table({
// Create refs for input elements
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
// Memoized template for empty cells for current columns
const emptyCellsTemplate = useMemo(
() => Object.fromEntries(columns.map((col) => [col, ''])),
[columns]
)
/**
* Initialize the table with a default empty row when the component mounts
* and when the current store value is missing or empty.
*/
useEffect(() => {
if (!isPreview && !disabled && (!Array.isArray(storeValue) || storeValue.length === 0)) {
const initialRow: TableRow = {
id: crypto.randomUUID(),
cells: { ...emptyCellsTemplate },
}
setStoreValue([initialRow])
}
}, [isPreview, disabled, storeValue, setStoreValue, emptyCellsTemplate])
// Ensure value is properly typed and initialized
const rows = useMemo(() => {
if (!Array.isArray(value)) {
if (!Array.isArray(value) || value.length === 0) {
return [
{
id: crypto.randomUUID(),
cells: Object.fromEntries(columns.map((col) => [col, ''])),
cells: { ...emptyCellsTemplate },
},
]
}
// Validate and fix each row to ensure proper structure
// Validate and normalize each row without in-place mutation
const validatedRows = value.map((row) => {
// Ensure row has an id
if (!row.id) {
row.id = crypto.randomUUID()
}
// Ensure row has cells object with proper structure
if (!row.cells || typeof row.cells !== 'object') {
const hasValidCells = row?.cells && typeof row.cells === 'object'
if (!hasValidCells) {
logger.warn('Fixing malformed table row:', row)
row.cells = Object.fromEntries(columns.map((col) => [col, '']))
} else {
// Ensure all required columns exist in cells
columns.forEach((col) => {
if (!(col in row.cells)) {
row.cells[col] = ''
}
})
}
return row
const normalizedCells = {
...emptyCellsTemplate,
...(hasValidCells ? row.cells : {}),
}
return {
id: row?.id ?? crypto.randomUUID(),
cells: normalizedCells,
}
})
return validatedRows as TableRow[]
}, [value, columns])
}, [value, emptyCellsTemplate])
// Helper to update a cell value
const updateCellValue = (rowIndex: number, column: string, newValue: string) => {
@@ -103,15 +118,13 @@ export function Table({
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
// Ensure the row has a proper cells object
if (!row.cells || typeof row.cells !== 'object') {
logger.warn('Fixing malformed row cells during cell change:', row)
row.cells = Object.fromEntries(columns.map((col) => [col, '']))
}
const hasValidCells = row.cells && typeof row.cells === 'object'
const baseCells = hasValidCells ? row.cells : { ...emptyCellsTemplate }
if (!hasValidCells) logger.warn('Fixing malformed row cells during cell change:', row)
return {
...row,
cells: { ...row.cells, [column]: newValue },
cells: { ...baseCells, [column]: newValue },
}
}
return row
@@ -120,7 +133,7 @@ export function Table({
if (rowIndex === rows.length - 1 && newValue !== '') {
updatedRows.push({
id: crypto.randomUUID(),
cells: Object.fromEntries(columns.map((col) => [col, ''])),
cells: { ...emptyCellsTemplate },
})
}
@@ -152,16 +165,12 @@ export function Table({
const renderCell = (row: TableRow, rowIndex: number, column: string, cellIndex: number) => {
// Defensive programming: ensure row.cells exists and has the expected structure
if (!row.cells || typeof row.cells !== 'object') {
logger.warn('Table row has malformed cells data:', row)
// Create a fallback cells object
row = {
...row,
cells: Object.fromEntries(columns.map((col) => [col, ''])),
}
}
const hasValidCells = row.cells && typeof row.cells === 'object'
if (!hasValidCells) logger.warn('Table row has malformed cells data:', row)
const cellValue = row.cells[column] || ''
const cells = hasValidCells ? row.cells : { ...emptyCellsTemplate }
const cellValue = cells[column] || ''
const cellKey = `${rowIndex}-${column}`
// Get field state and handlers for this cell

View File

@@ -817,11 +817,38 @@ try {
},
]
// Ensure modal overlay appears above Settings modal (z-index: 9999999)
useEffect(() => {
if (!open) return
const styleId = 'custom-tool-modal-z-index'
let styleEl = document.getElementById(styleId) as HTMLStyleElement
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
styleEl.textContent = `
[data-radix-portal] [data-radix-dialog-overlay] {
z-index: 99999998 !important;
}
`
document.head.appendChild(styleEl)
}
return () => {
const el = document.getElementById(styleId)
if (el) {
el.remove()
}
}
}, [open])
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className='flex h-[80vh] flex-col gap-0 p-0 sm:max-w-[700px]'
style={{ zIndex: 99999999 }}
hideCloseButton
onKeyDown={(e) => {
// Intercept Escape key when dropdowns are open

View File

@@ -210,8 +210,9 @@ export function Editor() {
/>
) : (
<h2
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
className='min-w-0 flex-1 cursor-pointer truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
title={title}
onDoubleClick={handleStartRename}
>
{title}
</h2>

View File

@@ -1,3 +1,2 @@
export { usePanelResize } from './use-panel-resize'
export { useRunWorkflow } from './use-run-workflow'
export { type UsageData, useUsageLimits } from './use-usage-limits'

View File

@@ -1,75 +0,0 @@
import { useCallback } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
const logger = createLogger('useRunWorkflow')
/**
* Custom hook to handle workflow execution with usage limit checks.
* Provides a unified way to run workflows from anywhere in the codebase.
*
* Features:
* - Automatic usage limit checking
* - Handles execution state
*
* @param options - Configuration options
* @param options.usageExceeded - Whether usage limit is exceeded (required)
* @param options.onBeforeRun - Optional callback before running workflow
* @param options.onAfterRun - Optional callback after running workflow
* @returns Run workflow function and related state
*/
export function useRunWorkflow(options: {
usageExceeded: boolean
onBeforeRun?: () => void | Promise<void>
onAfterRun?: () => void | Promise<void>
}) {
const { usageExceeded, onBeforeRun, onAfterRun } = options
const { handleRunWorkflow, isExecuting, handleCancelExecution } = useWorkflowExecution()
/**
* Runs the workflow with automatic checks and UI management
*/
const runWorkflow = useCallback(async () => {
try {
// Execute before run callback
if (onBeforeRun) {
await onBeforeRun()
}
// Check if usage is exceeded
if (usageExceeded) {
logger.warn('Usage limit exceeded, opening subscription settings')
return
}
// Run the workflow
await handleRunWorkflow(undefined)
// Execute after run callback
if (onAfterRun) {
await onAfterRun()
}
} catch (error) {
logger.error('Error running workflow:', { error })
}
}, [usageExceeded, onBeforeRun, onAfterRun, handleRunWorkflow])
/**
* Cancels the currently executing workflow
*/
const cancelWorkflow = useCallback(async () => {
try {
await handleCancelExecution()
logger.info('Workflow execution cancelled')
} catch (error) {
logger.error('Error cancelling workflow:', { error })
}
}, [handleCancelExecution])
return {
runWorkflow,
cancelWorkflow,
isExecuting,
}
}

View File

@@ -26,6 +26,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel-new/store'
@@ -35,7 +36,7 @@ import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { Copilot, Deploy, Editor, Toolbar } from './components'
import { usePanelResize, useRunWorkflow, useUsageLimits } from './hooks'
import { usePanelResize, useUsageLimits } from './hooks'
const logger = createLogger('Panel')
/**
@@ -99,12 +100,43 @@ export function Panel() {
autoRefresh: !isRegistryLoading,
})
// Run workflow hook
const { runWorkflow, cancelWorkflow, isExecuting } = useRunWorkflow({ usageExceeded })
// Workflow execution hook
const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution()
// Panel resize hook
const { handleMouseDown } = usePanelResize()
/**
* Opens subscription settings modal
*/
const openSubscriptionSettings = () => {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
)
}
}
/**
* Runs the workflow with usage limit check
*/
const runWorkflow = useCallback(async () => {
if (usageExceeded) {
openSubscriptionSettings()
return
}
await handleRunWorkflow()
}, [usageExceeded, handleRunWorkflow])
/**
* Cancels the currently executing workflow
*/
const cancelWorkflow = useCallback(async () => {
await handleCancelExecution()
}, [handleCancelExecution])
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore()
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()

View File

@@ -1,63 +0,0 @@
'use client'
import { useMemo } from 'react'
import { useSession } from '@/lib/auth-client'
import { useSocket } from '@/contexts/socket-context'
interface SocketPresenceUser {
socketId: string
userId: string
userName: string
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
type PresenceUser = {
connectionId: string | number
name?: string
color?: string
info?: string
avatarUrl?: string | null
}
interface UsePresenceReturn {
users: PresenceUser[]
currentUser: PresenceUser | null
isConnected: boolean
}
/**
* Hook for managing user presence in collaborative workflows using Socket.IO
* Uses the existing Socket context to get real presence data
* Filters out the current user so only other collaborators are shown
*/
export function usePresence(): UsePresenceReturn {
const { presenceUsers, isConnected } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const users = useMemo(() => {
const uniqueUsers = new Map<string, SocketPresenceUser>()
presenceUsers.forEach((user) => {
uniqueUsers.set(user.userId, user)
})
return Array.from(uniqueUsers.values())
.filter((user) => user.userId !== currentUserId)
.map((user) => ({
connectionId: user.userId,
name: user.userName,
color: undefined,
info: user.selection?.type ? `Editing ${user.selection.type}` : undefined,
avatarUrl: user.avatarUrl,
}))
}, [presenceUsers, currentUserId])
return {
users,
currentUser: null,
isConnected,
}
}

View File

@@ -20,6 +20,7 @@ import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useCurrentWorkflow } from './use-current-workflow'
const logger = createLogger('useWorkflowExecution')
@@ -671,10 +672,14 @@ export function useWorkflowExecution() {
const executionWorkflowState =
hasActiveDiffWorkflow && executionDiffWorkflow ? executionDiffWorkflow : null
const usingDiffForExecution = executionWorkflowState !== null
// Read blocks and edges directly from store to ensure we get the latest state,
// even if React hasn't re-rendered yet after adding blocks/edges
const latestWorkflowState = useWorkflowStore.getState().getWorkflowState()
const workflowBlocks = (executionWorkflowState?.blocks ??
currentWorkflow.blocks) as typeof currentWorkflow.blocks
latestWorkflowState.blocks) as typeof currentWorkflow.blocks
const workflowEdges = (executionWorkflowState?.edges ??
currentWorkflow.edges) as typeof currentWorkflow.edges
latestWorkflowState.edges) as typeof currentWorkflow.edges
// Filter out blocks without type (these are layout-only blocks)
const validBlocks = Object.entries(workflowBlocks).reduce(

View File

@@ -23,7 +23,6 @@ import {
TrainingControls,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
@@ -643,8 +642,7 @@ const WorkflowContent = React.memo(() => {
if (closestBlock) {
// Don't create edges into trigger blocks
const targetBlockConfig = blockConfig
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
@@ -924,7 +922,7 @@ const WorkflowContent = React.memo(() => {
// Don't create edges into trigger blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle({
@@ -1002,7 +1000,7 @@ const WorkflowContent = React.memo(() => {
// Don't create edges into trigger blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
@@ -2006,7 +2004,6 @@ const WorkflowContent = React.memo(() => {
<div className='flex h-screen w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
<div className='workflow-container h-full' />
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
</div>
<Panel />
<Terminal />
@@ -2020,8 +2017,6 @@ const WorkflowContent = React.memo(() => {
{/* Training Controls - for recording workflow edits */}
<TrainingControls />
<UserAvatarStack className='pointer-events-auto w-fit max-w-xs' />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}

View File

@@ -7,7 +7,7 @@ import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Modal, ModalContent } from '@/components/emcn'
import { Button, Input, Modal, ModalContent, ModalTitle } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -355,9 +355,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
<ModalContent className='flex h-[75vh] max-h-[75vh] w-full max-w-[700px] flex-col gap-0 p-0'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Help & Support
</h2>
</ModalTitle>
</div>
{/* Modal Body */}

View File

@@ -3,22 +3,17 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { Input, Label, Skeleton, Switch } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -374,7 +369,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
if (!allowPersonalApiKeys && keyType === 'personal') {
setKeyType('workspace')
}
}, [allowPersonalApiKeys, keyType])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowPersonalApiKeys])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
@@ -398,7 +394,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-2 pb-2'>
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
@@ -417,7 +413,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-2'>
<ApiKeySkeleton />
@@ -432,44 +428,46 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<>
{/* Allow Personal API Keys Toggle */}
{!searchTerm.trim() && (
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged to
them.
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Provider delayDuration={150}>
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged
to them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{workspaceSettingsLoading ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
onCheckedChange={async (checked) => {
const previous = allowPersonalApiKeys
setAllowPersonalApiKeys(checked)
try {
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
} catch (error) {
setAllowPersonalApiKeys(previous)
}
}}
/>
)}
</div>
{workspaceSettingsLoading ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
onCheckedChange={async (checked) => {
const previous = allowPersonalApiKeys
setAllowPersonalApiKeys(checked)
try {
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
} catch (error) {
setAllowPersonalApiKeys(previous)
}
}}
/>
)}
</div>
</Tooltip.Provider>
)}
{/* Workspace section */}
@@ -494,7 +492,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -527,7 +524,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -566,7 +562,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -607,17 +602,19 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
) : (
<Button
onClick={() => {
onClick={(e) => {
if (createButtonDisabled) {
return
}
// Remove focus from button before opening dialog to prevent focus trap
e.currentTarget.blur()
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='ghost'
disabled={createButtonDisabled}
className='h-8 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
@@ -627,16 +624,16 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
{/* Create API Key Dialog */}
<AlertDialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Create new API key</ModalTitle>
<ModalDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
<div className='space-y-4 py-2'>
{canManageWorkspaceKeys && (
@@ -645,26 +642,24 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
variant={keyType === 'personal' ? 'outline' : 'default'}
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='h-8 disabled:cursor-not-allowed disabled:opacity-60 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
className='h-8 disabled:cursor-not-allowed disabled:opacity-60'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
variant={keyType === 'workspace' ? 'outline' : 'default'}
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
className='h-8'
>
Workspace
</Button>
@@ -685,24 +680,30 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
className='h-9 rounded-[8px]'
autoFocus
/>
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
{createError && (
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{createError}
</div>
)}
</div>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
<ModalFooter className='flex'>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
setKeyType(defaultKeyType)
}}
>
Cancel
</AlertDialogCancel>
</Button>
<Button
type='button'
variant='primary'
onClick={handleCreateKey}
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
className='h-9 w-full rounded-[8px] disabled:cursor-not-allowed disabled:opacity-50'
disabled={
!newKeyName.trim() ||
isSubmittingCreate ||
@@ -711,14 +712,14 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
>
Create {keyType === 'workspace' ? 'Workspace' : 'Personal'} Key
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ModalFooter>
</ModalContent>
</Modal>
{/* New API Key Dialog */}
<AlertDialog
<Modal
open={showNewKeyDialog}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
@@ -726,14 +727,14 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}}
>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Your API key has been created</ModalTitle>
<ModalDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{newKey && (
<div className='relative'>
@@ -744,7 +745,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
@@ -753,19 +753,19 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</Button>
</div>
)}
</AlertDialogContent>
</AlertDialog>
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Delete API key?</ModalTitle>
<ModalDescription>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{deleteKey && (
<div className='py-2'>
@@ -783,17 +783,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
)}
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
<ModalFooter className='flex'>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setShowDeleteDialog(false)
setDeleteKey(null)
setDeleteConfirmationName('')
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<Button
onClick={() => {
handleDeleteKey()
setDeleteConfirmationName('')
@@ -802,10 +803,10 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
disabled={!deleteKey || deleteConfirmationName !== deleteKey.name}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -1,526 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Camera, Check, Globe, Linkedin, Mail, Save, Twitter, User, Users } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { AgentIcon } from '@/components/icons'
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
import type { CreatorProfileDetails } from '@/types/creator-profile'
const logger = createLogger('CreatorProfile')
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
const creatorProfileSchema = z.object({
referenceType: z.enum(['user', 'organization']),
referenceId: z.string().min(1, 'Reference is required'),
name: z.string().min(1, 'Display Name is required').max(100, 'Max 100 characters'),
profileImageUrl: z.string().min(1, 'Profile Picture is required'),
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
type CreatorProfileFormData = z.infer<typeof creatorProfileSchema>
interface Organization {
id: string
name: string
role: string
}
export function CreatorProfile() {
const { data: session } = useSession()
const [loading, setLoading] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [organizations, setOrganizations] = useState<Organization[]>([])
const [existingProfile, setExistingProfile] = useState<any>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const form = useForm<CreatorProfileFormData>({
resolver: zodResolver(creatorProfileSchema),
defaultValues: {
referenceType: 'user',
referenceId: session?.user?.id || '',
name: session?.user?.name || session?.user?.email || '',
profileImageUrl: '',
about: '',
xUrl: '',
linkedinUrl: '',
websiteUrl: '',
contactEmail: '',
},
})
const profileImageUrl = form.watch('profileImageUrl')
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profileImageUrl,
onUpload: async (url) => {
form.setValue('profileImageUrl', url || '')
setUploadError(null)
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
const referenceType = form.watch('referenceType')
// Fetch organizations
useEffect(() => {
const fetchOrganizations = async () => {
if (!session?.user?.id) return
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
const orgs = (data.organizations || []).filter(
(org: any) => org.role === 'owner' || org.role === 'admin'
)
setOrganizations(orgs)
}
} catch (error) {
logger.error('Error fetching organizations:', error)
}
}
fetchOrganizations()
}, [session?.user?.id])
// Load existing profile
useEffect(() => {
const loadProfile = async () => {
if (!session?.user?.id) return
setLoading(true)
try {
const response = await fetch(`/api/creator-profiles?userId=${session.user.id}`)
if (response.ok) {
const data = await response.json()
if (data.profiles && data.profiles.length > 0) {
const profile = data.profiles[0]
const details = profile.details as CreatorProfileDetails | null
setExistingProfile(profile)
form.reset({
referenceType: profile.referenceType,
referenceId: profile.referenceId,
name: profile.name || '',
profileImageUrl: profile.profileImageUrl || '',
about: details?.about || '',
xUrl: details?.xUrl || '',
linkedinUrl: details?.linkedinUrl || '',
websiteUrl: details?.websiteUrl || '',
contactEmail: details?.contactEmail || '',
})
}
}
} catch (error) {
logger.error('Error loading profile:', error)
} finally {
setLoading(false)
}
}
loadProfile()
}, [session?.user?.id, form])
const onSubmit = async (data: CreatorProfileFormData) => {
if (!session?.user?.id) return
setSaveStatus('saving')
try {
const details: CreatorProfileDetails = {}
if (data.about) details.about = data.about
if (data.xUrl) details.xUrl = data.xUrl
if (data.linkedinUrl) details.linkedinUrl = data.linkedinUrl
if (data.websiteUrl) details.websiteUrl = data.websiteUrl
if (data.contactEmail) details.contactEmail = data.contactEmail
const payload = {
referenceType: data.referenceType,
referenceId: data.referenceId,
name: data.name,
profileImageUrl: data.profileImageUrl,
details: Object.keys(details).length > 0 ? details : undefined,
}
const url = existingProfile
? `/api/creator-profiles/${existingProfile.id}`
: '/api/creator-profiles'
const method = existingProfile ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (response.ok) {
const result = await response.json()
setExistingProfile(result.data)
logger.info('Creator profile saved successfully')
setSaveStatus('saved')
// Reset to idle after 2 seconds
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
} else {
logger.error('Failed to save creator profile')
setSaveStatus('error')
setTimeout(() => {
setSaveStatus('idle')
}, 3000)
}
} catch (error) {
logger.error('Error saving creator profile:', error)
setSaveStatus('error')
setTimeout(() => {
setSaveStatus('idle')
}, 3000)
}
}
if (loading) {
return (
<div className='flex h-full items-center justify-center'>
<p className='text-muted-foreground'>Loading...</p>
</div>
)
}
return (
<div className='h-full overflow-y-auto p-6'>
<div className='mx-auto max-w-2xl space-y-6'>
<div>
<p className='text-muted-foreground text-sm'>
Set up your creator profile for publishing templates
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
{/* Profile Type - only show if user has organizations */}
{organizations.length > 0 && (
<FormField
control={form.control}
name='referenceType'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>Profile Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='user' id='user' />
<label
htmlFor='user'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<User className='h-4 w-4' />
Personal Profile
</label>
</div>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='organization' id='organization' />
<label
htmlFor='organization'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<Users className='h-4 w-4' />
Organization Profile
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Reference Selection */}
{referenceType === 'organization' && organizations.length > 0 && (
<FormField
control={form.control}
name='referenceId'
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Profile Name */}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>
Display Name <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<Input placeholder='How your name appears on templates' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Profile Picture Upload */}
<FormField
control={form.control}
name='profileImageUrl'
render={() => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Camera className='h-4 w-4' />
Profile Picture <span className='text-destructive'>*</span>
</div>
</FormLabel>
<FormControl>
<div className='space-y-2'>
<div className='relative inline-block'>
<div
className='group relative flex h-24 w-24 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{profilePictureUrl ? (
<Image
src={profilePictureUrl}
alt='Profile picture'
width={96}
height={96}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
) : (
<AgentIcon className='h-12 w-12 text-white' />
)}
{/* Upload overlay */}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-6 w-6 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
{uploadError && <p className='text-destructive text-sm'>{uploadError}</p>}
<p className='text-muted-foreground text-xs'>PNG or JPEG (max 5MB)</p>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* About */}
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel>About</FormLabel>
<FormControl>
<Textarea
placeholder='Tell people about yourself or your organization'
className='min-h-[120px] resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Social Links */}
<div className='space-y-4'>
<h3 className='font-medium text-sm'>Social Links</h3>
<FormField
control={form.control}
name='xUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Twitter className='h-4 w-4' />X (Twitter)
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://x.com/username' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='linkedinUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Linkedin className='h-4 w-4' />
LinkedIn
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://linkedin.com/in/username' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='websiteUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
Website
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://yourwebsite.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='contactEmail'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Mail className='h-4 w-4' />
Contact Email
</div>
</FormLabel>
<FormControl>
<Input placeholder='contact@example.com' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type='submit'
disabled={saveStatus === 'saving'}
className={cn(
'w-full transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'error' && <>Error Saving</>}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{existingProfile ? 'Update Profile' : 'Create Profile'}
</>
)}
</Button>
</form>
</Form>
</div>
</div>
)
}

View File

@@ -0,0 +1,543 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Camera, Check, User, Users } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Textarea } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
import type { CreatorProfileDetails } from '@/types/creator-profile'
const logger = createLogger('CreatorProfile')
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
const creatorProfileSchema = z.object({
referenceType: z.enum(['user', 'organization']),
referenceId: z.string().min(1, 'Reference is required'),
name: z.string().min(1, 'Display Name is required').max(100, 'Max 100 characters'),
profileImageUrl: z.string().min(1, 'Profile Picture is required'),
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
type CreatorProfileFormData = z.infer<typeof creatorProfileSchema>
interface Organization {
id: string
name: string
role: string
}
export function CreatorProfile() {
const { data: session } = useSession()
const [loading, setLoading] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [organizations, setOrganizations] = useState<Organization[]>([])
const [existingProfile, setExistingProfile] = useState<any>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const form = useForm<CreatorProfileFormData>({
resolver: zodResolver(creatorProfileSchema),
defaultValues: {
referenceType: 'user',
referenceId: session?.user?.id || '',
name: session?.user?.name || session?.user?.email || '',
profileImageUrl: '',
about: '',
xUrl: '',
linkedinUrl: '',
websiteUrl: '',
contactEmail: '',
},
})
const profileImageUrl = form.watch('profileImageUrl')
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profileImageUrl,
onUpload: async (url) => {
form.setValue('profileImageUrl', url || '')
setUploadError(null)
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
const referenceType = form.watch('referenceType')
// Fetch organizations
useEffect(() => {
const fetchOrganizations = async () => {
if (!session?.user?.id) return
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
const orgs = (data.organizations || []).filter(
(org: any) => org.role === 'owner' || org.role === 'admin'
)
setOrganizations(orgs)
}
} catch (error) {
logger.error('Error fetching organizations:', error)
}
}
fetchOrganizations()
}, [session?.user?.id])
// Load existing profile
useEffect(() => {
const loadProfile = async () => {
if (!session?.user?.id) return
setLoading(true)
try {
const response = await fetch(`/api/creator-profiles?userId=${session.user.id}`)
if (response.ok) {
const data = await response.json()
if (data.profiles && data.profiles.length > 0) {
const profile = data.profiles[0]
const details = profile.details as CreatorProfileDetails | null
setExistingProfile(profile)
form.reset({
referenceType: profile.referenceType,
referenceId: profile.referenceId,
name: profile.name || '',
profileImageUrl: profile.profileImageUrl || '',
about: details?.about || '',
xUrl: details?.xUrl || '',
linkedinUrl: details?.linkedinUrl || '',
websiteUrl: details?.websiteUrl || '',
contactEmail: details?.contactEmail || '',
})
}
}
} catch (error) {
logger.error('Error loading profile:', error)
} finally {
setLoading(false)
}
}
loadProfile()
}, [session?.user?.id, form])
const [saveError, setSaveError] = useState<string | null>(null)
const onSubmit = async (data: CreatorProfileFormData) => {
if (!session?.user?.id) return
setSaveStatus('saving')
setSaveError(null)
try {
const details: CreatorProfileDetails = {}
if (data.about) details.about = data.about
if (data.xUrl) details.xUrl = data.xUrl
if (data.linkedinUrl) details.linkedinUrl = data.linkedinUrl
if (data.websiteUrl) details.websiteUrl = data.websiteUrl
if (data.contactEmail) details.contactEmail = data.contactEmail
const payload = {
referenceType: data.referenceType,
referenceId: data.referenceId,
name: data.name,
profileImageUrl: data.profileImageUrl,
details: Object.keys(details).length > 0 ? details : undefined,
}
const url = existingProfile
? `/api/creator-profiles/${existingProfile.id}`
: '/api/creator-profiles'
const method = existingProfile ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (response.ok) {
const result = await response.json()
setExistingProfile(result.data)
logger.info('Creator profile saved successfully')
setSaveStatus('saved')
// Dispatch event to notify that a creator profile was saved
window.dispatchEvent(new CustomEvent('creator-profile-saved'))
// Reset to idle after 2 seconds
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
} else {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData.error || 'Failed to save creator profile'
logger.error('Failed to save creator profile')
setSaveError(errorMessage)
setSaveStatus('idle')
}
} catch (error) {
logger.error('Error saving creator profile:', error)
setSaveError('Failed to save creator profile. Please check your connection and try again.')
setSaveStatus('idle')
}
}
if (loading) {
return (
<div className='flex h-full items-center justify-center'>
<div className='space-y-2'>
<Skeleton className='h-9 w-64 rounded-[8px]' />
<Skeleton className='h-9 w-64 rounded-[8px]' />
<Skeleton className='h-9 w-64 rounded-[8px]' />
</div>
</div>
)
}
return (
<div className='relative flex h-full flex-col'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='flex h-full flex-col'>
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{/* Profile Type - only show if user has organizations */}
{organizations.length > 0 && (
<FormField
control={form.control}
name='referenceType'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>Profile Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='user' id='user' />
<label
htmlFor='user'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<User className='h-4 w-4' />
Personal Profile
</label>
</div>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='organization' id='organization' />
<label
htmlFor='organization'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<Users className='h-4 w-4' />
Organization Profile
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Reference Selection */}
{referenceType === 'organization' && organizations.length > 0 && (
<FormField
control={form.control}
name='referenceId'
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Profile Name */}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>
Display Name <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<Input
placeholder='How your name appears on templates'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Profile Picture Upload */}
<FormField
control={form.control}
name='profileImageUrl'
render={() => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>
Profile Picture <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<div className='flex items-center gap-3'>
<div className='relative inline-block'>
<div
className='group relative flex h-16 w-16 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{profilePictureUrl ? (
<Image
src={profilePictureUrl}
alt='Profile picture'
width={64}
height={64}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
) : (
<AgentIcon className='h-8 w-8 text-white' />
)}
{/* Upload overlay */}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-4 w-4 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
<div className='flex flex-col gap-1'>
{uploadError && <p className='text-destructive text-sm'>{uploadError}</p>}
<p className='text-muted-foreground text-xs'>PNG or JPEG (max 5MB)</p>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* About */}
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel className='font-normal text-[13px]'>About</FormLabel>
<FormControl>
<Textarea
placeholder='Tell people about yourself or your organization'
className='min-h-[120px] w-full resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Social Links */}
<div className='space-y-4'>
<div className='font-medium text-[13px] text-foreground'>Social Links</div>
<FormField
control={form.control}
name='xUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
X (Twitter)
</FormLabel>
<FormControl>
<Input
placeholder='https://x.com/username'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='linkedinUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
LinkedIn
</FormLabel>
<FormControl>
<Input
placeholder='https://linkedin.com/in/username'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='websiteUrl'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
Website
</FormLabel>
<FormControl>
<Input
placeholder='https://yourwebsite.com'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='contactEmail'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2 font-normal text-[13px]'>
Contact Email
</FormLabel>
<FormControl>
<Input
placeholder='contact@example.com'
type='email'
{...field}
className='h-9 w-full'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Error Message */}
{saveError && (
<div className='px-6 pb-2'>
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{saveError}
</div>
</div>
)}
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<div className='text-muted-foreground text-xs'>
Set up your creator profile for publishing templates
</div>
<Button type='submit' disabled={saveStatus === 'saving'} className='h-9'>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'idle' && (
<>{existingProfile ? 'Update Profile' : 'Create Profile'}</>
)}
</Button>
</div>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -3,10 +3,8 @@
import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/emcn'
import { Input, Label, Skeleton } from '@/components/ui'
import { client, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth'
@@ -386,7 +384,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-muted-foreground text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-muted-foreground'
>
@@ -462,7 +459,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={isConnecting === `${service.id}-${service.accounts![0].id}`}
className={cn(
@@ -476,7 +472,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
) : (
<Button
variant='outline'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className={cn('h-8', isConnecting === service.id && 'cursor-not-allowed')}

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { Button, Label } from '@/components/emcn'
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
@@ -12,13 +13,17 @@ const logger = createLogger('CustomToolsSettings')
function CustomToolSkeleton() {
return (
<div className='rounded-[8px] border bg-background p-4'>
<div className='flex items-center justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-3 w-48' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* Tool title */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-24 rounded-[8px]' /> {/* Function name */}
<Skeleton className='h-4 w-48' /> {/* Description */}
</div>
<div className='flex items-center gap-2'>
<Skeleton className='h-8 w-12' /> {/* Edit button */}
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
<Skeleton className='h-8 w-20' />
</div>
</div>
)
@@ -91,27 +96,22 @@ export function CustomTools() {
}
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='font-semibold text-foreground text-lg'>Custom Tools</h2>
<p className='mt-1 text-muted-foreground text-sm'>
Manage workspace-scoped custom tools for your agents
</p>
</div>
{!showAddForm && !editingTool && (
<Button size='sm' onClick={() => setShowAddForm(true)} className='h-9'>
<Plus className='mr-2 h-4 w-4' />
Add Tool
</Button>
)}
</div>
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-4 pb-2'>
{/* Error Alert - only show when modal is not open */}
{error && !showAddForm && !editingTool && (
<Alert variant='destructive' className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Search */}
{tools.length > 0 && !showAddForm && !editingTool && (
<div className='mt-4 flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search tools...'
@@ -121,79 +121,98 @@ export function CustomTools() {
/>
</div>
)}
{/* Error Alert - only show when modal is not open */}
{error && !showAddForm && !editingTool && (
<Alert variant='destructive' className='mt-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-4 py-2'>
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-4'>
<div className='space-y-2'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
) : filteredTools.length === 0 && !showAddForm && !editingTool ? (
) : tools.length === 0 && !showAddForm && !editingTool ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
{searchTerm.trim() ? (
<>No tools found matching "{searchTerm}"</>
) : (
<>Click "Add Tool" above to create your first custom tool</>
)}
Click "Create Tool" below to get started
</div>
) : (
<div className='space-y-4'>
{filteredTools.map((tool) => (
<div
key={tool.id}
className='flex items-center justify-between gap-4 rounded-[8px] border bg-background p-4'
>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<code className='font-medium font-mono text-foreground text-sm'>
{tool.title}
</code>
<>
<div className='space-y-2'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{tool.title}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>
{tool.schema?.function?.name || 'unnamed'}
</code>
</div>
{tool.schema?.function?.description && (
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => setEditingTool(tool.id)}
className='h-8'
>
Edit
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteTool(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
{tool.schema?.function?.description && (
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => setEditingTool(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
Edit
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDeleteTool(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
))}
))}
</div>
{/* Show message when search has no results */}
{searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No tools found matching "{searchTerm}"
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[200px]' />
</>
) : (
<>
<Button
onClick={() => setShowAddForm(true)}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Tool
</Button>
<div className='text-muted-foreground text-xs'>
Custom tools extend agent capabilities with workspace-specific functions
</div>
</>
)}
</div>
</div>

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Search, Share2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import {
AlertDialog,
AlertDialogAction,
@@ -14,9 +15,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { createLogger } from '@/lib/logs/console/logger'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
@@ -437,7 +435,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
onClick={() => {
if (!envVar.key || !envVar.value || !workspaceId) return
@@ -458,7 +455,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => removeEnvVar(originalIndex)}
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
>
@@ -532,7 +528,7 @@ export function EnvironmentVariables({
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading || isWorkspaceLoading ? (
<>
{/* Show 3 skeleton rows */}
@@ -584,7 +580,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setWorkspaceVars((prev) => {
const next = { ...prev }
@@ -638,7 +633,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setWorkspaceVars((prev) => {
const next = { ...prev }

View File

@@ -1,10 +1,10 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Download, Search, Trash2 } from 'lucide-react'
import { ArrowDown, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip, Trash } from '@/components/emcn'
import { Input, Progress, Skeleton } from '@/components/ui'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
@@ -51,7 +51,7 @@ interface StorageInfo {
percentUsed: number
}
export function FileUploads() {
export function Files() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const [files, setFiles] = useState<WorkspaceFileRecord[]>([])
@@ -351,7 +351,13 @@ export function FileUploads() {
</div>
{/* Error message */}
{uploadError && <div className='px-6 pb-2 text-red-600 text-sm'>{uploadError}</div>}
{uploadError && (
<div className='px-6 pb-2'>
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{uploadError}
</div>
</div>
)}
{/* Files Table */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
@@ -396,28 +402,34 @@ export function FileUploads() {
</TableCell>
<TableCell className='px-3'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon'
onClick={() => handleDownload(file)}
title='Download'
className='h-6 w-6'
aria-label={`Download ${file.name}`}
>
<Download className='h-3.5 w-3.5 text-muted-foreground' />
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleDownload(file)}
className='h-6 w-6 p-0'
aria-label={`Download ${file.name}`}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Download file</Tooltip.Content>
</Tooltip.Root>
{userPermissions.canEdit && (
<Button
variant='ghost'
size='icon'
onClick={() => handleDelete(file)}
className='h-6 w-6 text-destructive hover:text-destructive'
disabled={deletingFileId === file.id}
title='Delete'
aria-label={`Delete ${file.name}`}
>
<Trash2 className='h-3.5 w-3.5' />
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleDelete(file)}
className='h-6 w-6 p-0'
disabled={deletingFileId === file.id}
aria-label={`Delete ${file.name}`}
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete file</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</TableCell>

View File

@@ -4,7 +4,7 @@ export { Copilot } from './copilot/copilot'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { EnvironmentVariables } from './environment/environment'
export { FileUploads } from './file-uploads/file-uploads'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { MCP } from './mcp/mcp'
export { Privacy } from './privacy/privacy'

View File

@@ -1,7 +1,7 @@
'use client'
import { Plus, X } from 'lucide-react'
import { Button, Input, Label } from '@/components/ui'
import { Button, Input, Label } from '@/components/emcn'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import type { McpServerFormData, McpServerTestResult } from '../types'
@@ -181,7 +181,6 @@ export function AddServerForm({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => onRemoveHeader(key)}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
@@ -241,7 +240,6 @@ export function AddServerForm({
<Button
type='button'
variant='outline'
size='sm'
onClick={onAddHeader}
className='h-9 text-muted-foreground hover:text-foreground'
>
@@ -255,7 +253,9 @@ export function AddServerForm({
<div className='space-y-1.5'>
{/* Error message above buttons */}
{testResult && !testResult.success && (
<p className='text-red-600 text-sm'>{testResult.error || testResult.message}</p>
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{testResult.error || testResult.message}
</div>
)}
{/* Buttons row */}
@@ -263,26 +263,25 @@ export function AddServerForm({
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onTestConnection}
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
className='text-muted-foreground hover:text-foreground'
className='h-9 text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && <span className='text-green-600 text-xs'> Connected</span>}
{testResult?.success && (
<span className='text-muted-foreground text-xs'> Connected</span>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onCancel}
className='text-muted-foreground hover:text-foreground'
className='h-9 text-muted-foreground hover:text-foreground'
>
Cancel
</Button>
<Button
size='sm'
onClick={onAddServer}
disabled={
serversLoading ||

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { Button } from '@/components/emcn'
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
@@ -254,7 +255,7 @@ export function MCP() {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-2 pb-2'>
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{serversLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
@@ -281,7 +282,7 @@ export function MCP() {
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{/* Server List */}
{serversLoading ? (
<div className='space-y-2'>
@@ -367,7 +368,6 @@ export function MCP() {
</div>
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveServer(server.id)}
disabled={deletingServers.has(server.id)}
className='h-8 text-muted-foreground hover:text-foreground'

View File

@@ -114,7 +114,7 @@ const allNavigationItems: NavigationItem[] = [
},
{
id: 'files',
label: 'File Uploads',
label: 'Files',
icon: Files,
},
// {

View File

@@ -680,7 +680,7 @@ export function SSO() {
))}
</select>
{showErrors && errors.providerId.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.providerId.join(' ')}</p>
</div>
)}
@@ -711,7 +711,7 @@ export function SSO() {
)}
/>
{showErrors && errors.issuerUrl.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.issuerUrl.join(' ')}</p>
</div>
)}
@@ -740,7 +740,7 @@ export function SSO() {
)}
/>
{showErrors && errors.domain.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.domain.join(' ')}</p>
</div>
)}
@@ -771,7 +771,7 @@ export function SSO() {
)}
/>
{showErrors && errors.clientId.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.clientId.join(' ')}</p>
</div>
)}
@@ -820,7 +820,7 @@ export function SSO() {
</button>
</div>
{showErrors && errors.clientSecret.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.clientSecret.join(' ')}</p>
</div>
)}
@@ -845,7 +845,7 @@ export function SSO() {
)}
/>
{showErrors && errors.scopes.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.scopes.join(' ')}</p>
</div>
)}
@@ -875,7 +875,7 @@ export function SSO() {
)}
/>
{showErrors && errors.entryPoint.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.entryPoint.join(' ')}</p>
</div>
)}
@@ -901,7 +901,7 @@ export function SSO() {
rows={4}
/>
{showErrors && errors.cert.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.cert.join(' ')}</p>
</div>
)}

View File

@@ -161,7 +161,11 @@ export function MemberInvitationCard({
className={cn('w-full', emailError && 'border-red-500 focus-visible:ring-red-500')}
/>
<div className='h-4 pt-1'>
{emailError && <p className='text-red-500 text-xs'>{emailError}</p>}
{emailError && (
<p className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{emailError}
</p>
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Modal, ModalContent } from '@/components/emcn'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { Modal, ModalContent, ModalDescription, ModalTitle } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -20,7 +21,7 @@ import {
Subscription,
TeamManagement,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/components/creator-profile/creator-profile'
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
import { useOrganizationStore } from '@/stores/organization'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -90,10 +91,16 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
onOpenChange(true)
}
const handleCloseSettings = () => {
onOpenChange(false)
}
window.addEventListener('open-settings', handleOpenSettings as EventListener)
window.addEventListener('close-settings', handleCloseSettings as EventListener)
return () => {
window.removeEventListener('open-settings', handleOpenSettings as EventListener)
window.removeEventListener('close-settings', handleCloseSettings as EventListener)
}
}, [onOpenChange])
@@ -120,6 +127,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return (
<Modal open={open} onOpenChange={handleDialogOpenChange}>
<ModalContent className='flex h-[70vh] w-full max-w-[840px] flex-col gap-0 p-0'>
<VisuallyHidden.Root>
<ModalTitle>Settings</ModalTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root>
<ModalDescription>
Configure your workspace settings, environment variables, credentials, and preferences
</ModalDescription>
</VisuallyHidden.Root>
<div className='flex flex-col border-[var(--surface-11)] border-b px-[16px] py-[12px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Settings

View File

@@ -1,22 +1,32 @@
'use client'
import { useEffect } from 'react'
import { Badge, Progress, Skeleton } from '@/components/ui'
import { useEffect, useMemo } from 'react'
import { Button } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
// Constants for reusable styles
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'
const GRADIENT_TEXT_STYLES =
'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
const CONTAINER_STYLES =
'pointer-events-auto flex-shrink-0 rounded-[10px] border bg-background px-3 py-2.5 shadow-xs cursor-pointer transition-colors hover:bg-muted/50'
const logger = createLogger('UsageIndicator')
// Plan name mapping
/**
* Minimum number of pills to display (at minimum sidebar width)
*/
const MIN_PILL_COUNT = 6
/**
* Maximum number of pills to display
*/
const MAX_PILL_COUNT = 8
/**
* Width increase (in pixels) required to add one additional pill
*/
const WIDTH_PER_PILL = 50
/**
* Plan name mapping
*/
const PLAN_NAMES = {
enterprise: 'Enterprise',
team: 'Team',
@@ -30,26 +40,40 @@ interface UsageIndicatorProps {
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const { getUsage, getSubscriptionStatus, isLoading } = useSubscriptionStore()
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
useEffect(() => {
useSubscriptionStore.getState().loadData()
}, [])
/**
* Calculate pill count based on sidebar width
* Starts at MIN_PILL_COUNT at minimum width, adds 1 pill per WIDTH_PER_PILL increase
*/
const pillCount = useMemo(() => {
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
const additionalPills = Math.floor(widthDelta / WIDTH_PER_PILL)
const calculatedCount = MIN_PILL_COUNT + additionalPills
return Math.max(MIN_PILL_COUNT, Math.min(MAX_PILL_COUNT, calculatedCount))
}, [sidebarWidth])
const usage = getUsage()
const subscription = getSubscriptionStatus()
if (isLoading) {
return (
<div className={CONTAINER_STYLES} onClick={() => onClick?.()}>
<div className='space-y-2'>
{/* Plan and usage info skeleton */}
<div className='flex items-center justify-between'>
<Skeleton className='h-5 w-12' />
<Skeleton className='h-4 w-20' />
</div>
<div className='flex flex-shrink-0 flex-col gap-[10px] border-t px-[13.5px] pt-[10px] pb-[8px] dark:border-[var(--border)]'>
{/* Top row skeleton */}
<div className='flex items-center justify-between'>
<Skeleton className='h-[16px] w-[120px] rounded-[4px]' />
<Skeleton className='h-[16px] w-[50px] rounded-[4px]' />
</div>
{/* Progress Bar skeleton */}
<Skeleton className='h-2 w-full' />
{/* Pills skeleton */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: pillCount }).map((_, i) => (
<Skeleton key={i} className='h-[6px] flex-1 rounded-[2px]' />
))}
</div>
</div>
)
@@ -67,7 +91,13 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const billingStatus = useSubscriptionStore.getState().getBillingStatus()
const isBlocked = billingStatus === 'blocked'
const badgeText = isBlocked ? 'Payment Failed' : planType === 'free' ? 'Upgrade' : undefined
const showUpgradeButton = planType === 'free' || isBlocked
/**
* Calculate which pills should be filled based on usage percentage
*/
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
const isAlmostOut = filledPillsCount === pillCount
const handleClick = () => {
try {
@@ -91,32 +121,56 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
return (
<div className={CONTAINER_STYLES} onClick={handleClick}>
<div className='space-y-2'>
{/* Plan and usage info */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span
className={cn(
'font-medium text-sm',
planType === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
)}
>
{PLAN_NAMES[planType]}
</span>
{badgeText ? <Badge className={GRADIENT_BADGE_STYLES}>{badgeText}</Badge> : null}
<div className='flex flex-shrink-0 flex-col gap-[10px] border-t px-[13.5px] pt-[8px] pb-[8px] dark:border-[var(--border)]'>
{/* Top row */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[#FFFFFF] text-[12px]'>{PLAN_NAMES[planType]}</span>
<div className='h-[14px] w-[1.5px] bg-[#4A4A4A]' />
<div className='flex items-center gap-[4px]'>
{isBlocked ? (
<>
<span className='font-medium text-[#B1B1B1] text-[12px]'>Over</span>
<span className='font-medium text-[#B1B1B1] text-[12px]'>limit</span>
</>
) : (
<>
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
${usage.current.toFixed(2)}
</span>
<span className='font-medium text-[#B1B1B1] text-[12px]'>/</span>
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
${usage.limit}
</span>
</>
)}
</div>
<span className='text-muted-foreground text-xs tabular-nums'>
{isBlocked ? 'Payment required' : `$${usage.current.toFixed(2)} / $${usage.limit}`}
</span>
</div>
{showUpgradeButton && (
<Button
variant='ghost'
className='!h-auto !px-1 !py-0 -mx-1 mt-[-2px] text-[#D4D4D4]'
onClick={handleClick}
>
Upgrade
</Button>
)}
</div>
{/* Progress Bar */}
<Progress
value={isBlocked ? 100 : progressPercentage}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
{/* Pills row */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: pillCount }).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>
</div>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
@@ -17,9 +17,76 @@ interface AvatarsProps {
onPresenceChange?: (hasAvatars: boolean) => void
}
interface PresenceUser {
socketId: string
userId: string
userName?: string
avatarUrl?: string | null
}
interface UserAvatarProps {
user: PresenceUser
index: number
}
/**
* Individual user avatar with error handling for image loading.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl) && !imageError
// Reset error state when avatar URL changes
useEffect(() => {
setImageError(false)
}, [user.avatarUrl])
const avatarElement = (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={user.avatarUrl.startsWith('http')}
onError={() => setImageError(true)}
/>
) : (
initials
)}
</div>
)
if (user.userName) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
}
/**
* Displays user avatars for presence in a workflow item.
* Consolidated logic from user-avatar-stack and user-avatar components.
* Only shows avatars for the currently active workflow.
*
* @param props - Component props
@@ -69,51 +136,9 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
return (
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => {
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl)
const avatarElement = (
<div
key={user.socketId}
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={user.avatarUrl.startsWith('http')}
/>
) : (
initials
)}
</div>
)
if (user.userName) {
return (
<Tooltip.Root key={user.socketId}>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
})}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
{overflowCount > 0 && (
<Tooltip.Root>

View File

@@ -33,7 +33,6 @@ const logger = createLogger('SidebarNew')
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
// const isBillingEnabled = true
/**
* Sidebar component with resizable width that persists across page refreshes.
@@ -610,11 +609,7 @@ export function SidebarNew() {
</div>
{/* Usage Indicator */}
{isBillingEnabled && (
<div className='flex flex-shrink-0 flex-col gap-[2px] border-t px-[7.75px] pt-[8px] pb-[8px] dark:border-[var(--border)]'>
<UsageIndicator />
</div>
)}
{isBillingEnabled && <UsageIndicator />}
{/* Footer Navigation */}
<FooterNavigation />

View File

@@ -0,0 +1,23 @@
'use client'
import { useEffect } from 'react'
import { configure } from 'onedollarstats'
import { env } from '@/lib/env'
export function OneDollarStats() {
useEffect(() => {
const shouldInitialize = !!env.DRIZZLE_ODS_API_KEY
if (!shouldInitialize) {
return
}
configure({
collectorUrl: 'https://collector.onedollarstats.com/events',
autocollect: true,
hashRouting: true,
})
}, [])
return null
}

View File

@@ -15,7 +15,11 @@ const buttonVariants = cva(
outline:
'border border-[#727272] bg-[var(--border-strong)] hover:bg-[var(--surface-11)] dark:border-[#727272] dark:bg-[var(--border-strong)] dark:hover:bg-[var(--surface-11)]',
primary:
'bg-[var(--brand-400)] dark:bg-[var(--brand-400)] dark:text-[var(--text-primary)] text-[var(--text-primary)] hover:bg-[var(--brand-400)] hover:dark:bg-[var(--brand-400)] hover:text-[var(--text-primary)] hover:dark:text-[var(--text-primary)]',
'bg-[var(--brand-400)] dark:bg-[var(--brand-400)] dark:text-[var(--text-primary)] text-[var(--text-primary)] hover:brightness-110 hover:text-[var(--text-primary)] hover:dark:text-[var(--text-primary)]',
secondary:
'bg-[var(--brand-secondary)] dark:bg-[var(--brand-secondary)] dark:text-[var(--text-primary)] text-[var(--text-primary)] hover:bg-[var(--brand-secondary)] hover:dark:bg-[var(--brand-secondary)] hover:text-[var(--text-primary)] hover:dark:text-[var(--text-primary)]',
tertiary:
'bg-[var(--brand-tertiary)] dark:bg-[var(--brand-tertiary)] dark:text-[var(--text-primary)] text-[var(--text-primary)] hover:bg-[var(--brand-tertiary)] hover:dark:bg-[var(--brand-tertiary)] hover:text-[var(--text-primary)] hover:dark:text-[var(--text-primary)]',
ghost: '',
'ghost-secondary': 'text-[var(--text-muted)] dark:text-[var(--text-muted)]',
},

View File

@@ -80,6 +80,10 @@ function Container({
'group relative min-h-[100px] rounded-[4px] border border-[var(--border-strong)]',
'bg-[#1F1F1F] font-medium font-mono text-sm transition-colors',
'dark:border-[var(--border-strong)]',
// Overflow handling for long content
'overflow-x-auto',
// Vertical resize handle
'resize-y overflow-y-auto',
// Streaming state
isStreaming && 'streaming-effect',
className

View File

@@ -8,7 +8,7 @@ authors:
- waleed
- emir
readingTime: 4
tags: [Announcement, Funding, Series A, Sim]
tags: [Announcement, Funding, Series A, Sim, YCombinator]
ogImage: /studio/series-a/cover.png
ogAlt: 'Sim team photo in front of neon logo'
about: ['Artificial Intelligence', 'Agentic Workflows', 'Startups', 'Funding']

View File

@@ -5,7 +5,7 @@ import {
generateEncryptedApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/service'
} from '@/lib/api-key/crypto'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -0,0 +1,131 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ApiKeyCrypto')
/**
* Get the API encryption key from the environment
* @returns The API encryption key
*/
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key
* @param apiKey - The API key to encrypt
* @returns A promise that resolves to an object containing the encrypted API key and IV
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
// If no API encryption key is set, return the key as-is for backward compatibility
if (!key) {
return { encrypted: apiKey, iv: '' }
}
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
// Format: iv:encrypted:authTag
return {
encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
iv: iv.toString('hex'),
}
}
/**
* Decrypts an API key using the dedicated API encryption key
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
* @returns A promise that resolves to an object containing the decrypted API key
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
// Check if this is actually encrypted (contains colons)
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
// This is a plain text key, return as-is
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
// If no API encryption key is set, assume it's plain text
if (!key) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const ivHex = parts[0]
const authTagHex = parts[parts.length - 1]
const encrypted = parts.slice(1, -1).join(':')
if (!ivHex || !encrypted || !authTagHex) {
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
}
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
try {
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return { decrypted }
} catch (error: unknown) {
logger.error('API key decryption error:', {
error: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${randomBytes(24).toString('base64url')}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${randomBytes(24).toString('base64url')}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}

View File

@@ -1,9 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { db } from '@sim/db'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { authenticateApiKey } from '@/lib/api-key/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -167,129 +165,3 @@ export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
logger.error('Error updating API key last used:', error)
}
}
/**
* Get the API encryption key from the environment
* @returns The API encryption key
*/
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key
* @param apiKey - The API key to encrypt
* @returns A promise that resolves to an object containing the encrypted API key and IV
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
// If no API encryption key is set, return the key as-is for backward compatibility
if (!key) {
return { encrypted: apiKey, iv: '' }
}
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
// Format: iv:encrypted:authTag
return {
encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
iv: iv.toString('hex'),
}
}
/**
* Decrypts an API key using the dedicated API encryption key
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
* @returns A promise that resolves to an object containing the decrypted API key
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
// Check if this is actually encrypted (contains colons)
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
// This is a plain text key, return as-is
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
// If no API encryption key is set, assume it's plain text
if (!key) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const ivHex = parts[0]
const authTagHex = parts[parts.length - 1]
const encrypted = parts.slice(1, -1).join(':')
if (!ivHex || !encrypted || !authTagHex) {
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
}
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
try {
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return { decrypted }
} catch (error: unknown) {
logger.error('API key decryption error:', {
error: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${randomBytes(24).toString('base64url')}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${randomBytes(24).toString('base64url')}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}

View File

@@ -96,6 +96,7 @@ export const env = createEnv({
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
DRIZZLE_ODS_API_KEY: z.string().min(1).optional(), // OneDollarStats API key for analytics tracking
// External Services
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation

View File

@@ -38,6 +38,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
"'unsafe-eval'",
'https://*.google.com',
'https://apis.google.com',
'https://assets.onedollarstats.com',
],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
@@ -92,6 +93,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'https://*.supabase.co',
'https://api.github.com',
'https://github.com/*',
'https://collector.onedollarstats.com',
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_TERMS_URL),
@@ -149,12 +151,12 @@ export function generateRuntimeCSP(): string {
return `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com ${brandLogoDomain} ${brandFaviconDomain};
media-src 'self' blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co ${dynamicDomainsStr};
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://collector.onedollarstats.com ${dynamicDomainsStr};
frame-src https://drive.google.com https://docs.google.com https://*.google.com;
frame-ancestors 'self';
form-action 'self';

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 MiB

After

Width:  |  Height:  |  Size: 21 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

View File

@@ -10,6 +10,7 @@
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",
"socket.io-client": "4.8.1",
@@ -2494,6 +2495,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onedollarstats": ["onedollarstats@0.0.10", "", {}, "sha512-+s2o5qBuKej2BrbJDqVRZr9U7F0ERBsNjXIJs1DSy2yK4yNk8z5iM0nHuwhelbNgqyVEwckCV7BJ9MsP/c8kQw=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],

View File

@@ -40,6 +40,7 @@
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",
"socket.io-client": "4.8.1",