mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c8fa2a77 | ||
|
|
766c7fbfbb | ||
|
|
675c42188a | ||
|
|
f414ae1936 | ||
|
|
ead0db9d2a | ||
|
|
10288111a8 | ||
|
|
01183f1771 | ||
|
|
ff081714e4 | ||
|
|
36bcd75832 | ||
|
|
9db969b1e0 | ||
|
|
2fbe0de5d3 | ||
|
|
6315cc105b | ||
|
|
61404d48a3 | ||
|
|
dbf9097a5b | ||
|
|
79b318fd9c | ||
|
|
cb39e697e2 | ||
|
|
e1a46c90c6 | ||
|
|
c7560be282 | ||
|
|
63f18995da | ||
|
|
af501347bb |
19
README.md
19
README.md
@@ -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
|
||||
|
||||
@@ -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
@@ -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'>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} font-season`}>{children}</div>
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} font-season`}>{children}</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavigationTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface NavigationTabsProps {
|
||||
tabs: NavigationTab[]
|
||||
activeTab?: string
|
||||
onTabClick?: (tabId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
className={cn(
|
||||
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
|
||||
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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' />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { usePanelResize } from './use-panel-resize'
|
||||
export { useRunWorkflow } from './use-run-workflow'
|
||||
export { type UsageData, useUsageLimits } from './use-usage-limits'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -114,7 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'File Uploads',
|
||||
label: 'Files',
|
||||
icon: Files,
|
||||
},
|
||||
// {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
23
apps/sim/components/analytics/onedollarstats.tsx
Normal file
23
apps/sim/components/analytics/onedollarstats.tsx
Normal 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
|
||||
}
|
||||
@@ -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)]',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
131
apps/sim/lib/api-key/crypto.ts
Normal file
131
apps/sim/lib/api-key/crypto.ts
Normal 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-')
|
||||
}
|
||||
@@ -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-')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
BIN
apps/sim/public/static/copilot.gif
Normal file
BIN
apps/sim/public/static/copilot.gif
Normal file
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 |
BIN
apps/sim/public/static/workflow.gif
Normal file
BIN
apps/sim/public/static/workflow.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 MiB |
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user