mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
feat(platform): new UI and templates (#639)
* improvement: control bar * improvement: debug flow * improvement: control bar hovers and skeleton loading * improvement: completed control bar * improvement: panel tab selector complete * refactor: deleted notifications and history dropdown * improvement: chat UI complete * fix: tab change on control bar run * improvement: finshed console (audio display not working) * fix: text wrapping in console content * improvement: audio UI * improvement: image display * feat: add input to console * improvement: code input and showing input on errors * feat: download chat and console * improvement: expandable panel and console visibility * improvement: empty state UI * improvement: finished variables * fix: image in console entry * improvement: sidebar and templates ui * feat: uploading and fetching templates * improvement: sidebar and control bar * improvement: templates * feat: templates done
This commit is contained in:
@@ -5,7 +5,6 @@ import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
@@ -22,7 +21,6 @@ export function SocialLoginButtons({
|
||||
}: SocialLoginButtonsProps) {
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Set mounted state to true on client-side
|
||||
@@ -57,8 +55,6 @@ export function SocialLoginButtons({
|
||||
} else if (err.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
} finally {
|
||||
setIsGithubLoading(false)
|
||||
}
|
||||
@@ -89,8 +85,6 @@ export function SocialLoginButtons({
|
||||
} else if (err.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
} finally {
|
||||
setIsGoogleLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { GridPattern } from '../(landing)/components/grid-pattern'
|
||||
import { NotificationList } from '../workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications'
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -31,11 +30,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className='fixed right-4 bottom-4 z-50'>
|
||||
<NotificationList />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
|
||||
@@ -35,7 +34,6 @@ export function useVerification({
|
||||
}: UseVerificationParams): UseVerificationReturn {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { addNotification } = useNotificationStore()
|
||||
const [otp, setOtp] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -46,13 +44,6 @@ export function useVerification({
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
|
||||
// Debug notification store
|
||||
useEffect(() => {
|
||||
logger.info('Notification store state:', {
|
||||
addNotification: !!addNotification,
|
||||
})
|
||||
}, [addNotification])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Get stored email
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceInfoAPI')
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Validate access to the workflow
|
||||
const validation = await validateWorkflowAccess(request, id, false)
|
||||
if (validation.error) {
|
||||
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
// Fetch marketplace data for the workflow
|
||||
const marketplaceEntry = await db
|
||||
.select()
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found for workflow: ${id}`)
|
||||
return createErrorResponse('Workflow is not published to marketplace', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved marketplace info for workflow: ${id}`)
|
||||
|
||||
return createSuccessResponse({
|
||||
id: marketplaceEntry.id,
|
||||
name: marketplaceEntry.name,
|
||||
description: marketplaceEntry.description,
|
||||
category: marketplaceEntry.category,
|
||||
authorName: marketplaceEntry.authorName,
|
||||
views: marketplaceEntry.views,
|
||||
createdAt: marketplaceEntry.createdAt,
|
||||
updatedAt: marketplaceEntry.updatedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${requestId}] Error getting marketplace info for workflow: ${(await params).id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse('Failed to get marketplace information', 500)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { marketplace, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceUnpublishAPI')
|
||||
|
||||
/**
|
||||
* API endpoint to unpublish a workflow from the marketplace by its marketplace ID
|
||||
*
|
||||
* Security:
|
||||
* - Requires authentication
|
||||
* - Validates that the current user is the author of the marketplace entry
|
||||
* - Only allows the owner to unpublish
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Get the session first for authorization
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized unpublish attempt for marketplace ID: ${id}`)
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Get the marketplace entry using the marketplace ID
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
authorId: marketplace.authorId,
|
||||
name: marketplace.name,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`)
|
||||
return createErrorResponse('Marketplace entry not found', 404)
|
||||
}
|
||||
|
||||
// Check if the user is the author of the marketplace entry
|
||||
if (marketplaceEntry.authorId !== userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} tried to unpublish marketplace entry they don't own: ${id}, author: ${marketplaceEntry.authorId}`
|
||||
)
|
||||
return createErrorResponse('You do not have permission to unpublish this workflow', 403)
|
||||
}
|
||||
|
||||
const workflowId = marketplaceEntry.workflowId
|
||||
|
||||
// Verify the workflow exists and belongs to the user
|
||||
const workflowEntry = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflowEntry) {
|
||||
logger.warn(`[${requestId}] Associated workflow not found: ${workflowId}`)
|
||||
// We'll still delete the marketplace entry even if the workflow is missing
|
||||
} else if (workflowEntry.userId !== userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workflow ${workflowId} belongs to user ${workflowEntry.userId}, not current user ${userId}`
|
||||
)
|
||||
return createErrorResponse('You do not have permission to unpublish this workflow', 403)
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the marketplace entry - this is the primary action
|
||||
await db.delete(marketplace).where(eq(marketplace.id, id))
|
||||
|
||||
// Update the workflow to mark it as unpublished if it exists
|
||||
if (workflowEntry) {
|
||||
await db.update(workflow).set({ isPublished: false }).where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Workflow "${marketplaceEntry.name}" unpublished from marketplace: ID=${id}, workflowId=${workflowId}`
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
message: 'Workflow successfully unpublished from marketplace',
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error(`[${requestId}] Database error unpublishing marketplace entry:`, dbError)
|
||||
return createErrorResponse('Failed to unpublish workflow due to a database error', 500)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error unpublishing marketplace entry: ${(await params).id}`, error)
|
||||
return createErrorResponse('Failed to unpublish workflow', 500)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceViewAPI')
|
||||
|
||||
/**
|
||||
* POST handler for incrementing the view count when a workflow card is clicked
|
||||
* This endpoint is called from the WorkflowCard component's onClick handler
|
||||
*
|
||||
* The ID parameter is the marketplace entry ID, not the workflow ID
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Find the marketplace entry for this marketplace ID
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`)
|
||||
return createErrorResponse('Marketplace entry not found', 404)
|
||||
}
|
||||
|
||||
// Increment the view count for this workflow
|
||||
await db
|
||||
.update(marketplace)
|
||||
.set({
|
||||
views: sql`${marketplace.views} + 1`,
|
||||
})
|
||||
.where(eq(marketplace.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${requestId}] Error incrementing view count for marketplace entry: ${(await params).id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse('Failed to track view', 500)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { marketplace, user, workflow } from '@/db/schema'
|
||||
|
||||
// Create a logger for this module
|
||||
const logger = createLogger('MarketplacePublishAPI')
|
||||
|
||||
// No cache
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// Schema for request body
|
||||
const PublishRequestSchema = z.object({
|
||||
workflowId: z.string().uuid(),
|
||||
name: z.string().min(3).max(50).optional(),
|
||||
description: z.string().min(10).max(500).optional(),
|
||||
category: z.string().min(1).optional(),
|
||||
authorName: z.string().min(2).max(50).optional(),
|
||||
workflowState: z.record(z.any()).optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized marketplace publish attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
const { workflowId, name, description, category, authorName, workflowState } =
|
||||
PublishRequestSchema.parse(body)
|
||||
|
||||
// Check if the workflow belongs to the user
|
||||
const userWorkflow = await db
|
||||
.select({ id: workflow.id, name: workflow.name, description: workflow.description })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!userWorkflow.length || userWorkflow[0].id !== workflowId) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to publish workflow they don't own: ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get the user's name for attribution
|
||||
const userData = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData.length) {
|
||||
logger.error(`[${requestId}] User data not found for ID: ${userId}`)
|
||||
return NextResponse.json({ error: 'User data not found' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify we have the workflow state
|
||||
if (!workflowState) {
|
||||
logger.error(`[${requestId}] No workflow state provided for ID: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow state is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if this workflow is already published
|
||||
const existingPublication = await db
|
||||
.select({ id: marketplace.id })
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, workflowId))
|
||||
.limit(1)
|
||||
|
||||
let result
|
||||
const marketplaceId = existingPublication.length ? existingPublication[0].id : uuidv4()
|
||||
|
||||
// Prepare the marketplace entry
|
||||
const marketplaceEntry = {
|
||||
id: marketplaceId,
|
||||
workflowId,
|
||||
state: workflowState,
|
||||
name: name || userWorkflow[0].name,
|
||||
description: description || userWorkflow[0].description || '',
|
||||
authorId: userId,
|
||||
authorName: authorName || userData[0].name,
|
||||
category: category || null,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (existingPublication.length) {
|
||||
// Update existing entry
|
||||
result = await db
|
||||
.update(marketplace)
|
||||
.set(marketplaceEntry)
|
||||
.where(eq(marketplace.id, marketplaceId))
|
||||
.returning()
|
||||
} else {
|
||||
// Create new entry with createdAt
|
||||
result = await db
|
||||
.insert(marketplace)
|
||||
.values({
|
||||
...marketplaceEntry,
|
||||
createdAt: new Date(),
|
||||
views: 0,
|
||||
})
|
||||
.returning()
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully published workflow to marketplace`, {
|
||||
workflowId,
|
||||
marketplaceId,
|
||||
userId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Workflow published successfully',
|
||||
data: {
|
||||
id: result[0].id,
|
||||
workflowId: result[0].workflowId,
|
||||
name: result[0].name,
|
||||
},
|
||||
})
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid marketplace publish request parameters`, {
|
||||
errors: validationError.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request parameters',
|
||||
details: validationError.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
throw validationError
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Marketplace publish error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import { desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { CATEGORIES } from '@/app/workspace/[workspaceId]/marketplace/constants/categories'
|
||||
import { db } from '@/db'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceWorkflowsAPI')
|
||||
|
||||
// Cache for 1 minute but can be revalidated on-demand
|
||||
export const revalidate = 60
|
||||
|
||||
/**
|
||||
* Consolidated API endpoint for marketplace workflows
|
||||
*
|
||||
* Supports:
|
||||
* - Getting featured/popular/recent workflows
|
||||
* - Getting workflows by category
|
||||
* - Getting workflow state
|
||||
* - Getting workflow details
|
||||
* - Incrementing view counts
|
||||
*
|
||||
* Query parameters:
|
||||
* - section: 'popular', 'recent', 'byCategory', or specific category name
|
||||
* - limit: Maximum number of items to return per section (default: 6)
|
||||
* - includeState: Whether to include workflow state in the response (default: false)
|
||||
* - workflowId: Specific workflow ID to fetch details for
|
||||
* - marketplaceId: Specific marketplace entry ID to fetch details for
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Parse query parameters
|
||||
const url = new URL(request.url)
|
||||
const sectionParam = url.searchParams.get('section')
|
||||
const categoryParam = url.searchParams.get('category')
|
||||
const limitParam = url.searchParams.get('limit') || '6'
|
||||
const limit = Number.parseInt(limitParam, 10)
|
||||
const includeState = url.searchParams.get('includeState') === 'true'
|
||||
const workflowId = url.searchParams.get('workflowId')
|
||||
const marketplaceId = url.searchParams.get('marketplaceId')
|
||||
|
||||
// Handle single workflow request first (by workflow ID)
|
||||
if (workflowId) {
|
||||
let marketplaceEntry
|
||||
|
||||
if (includeState) {
|
||||
// Query with state included
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
state: marketplace.state,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
} else {
|
||||
// Query without state
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
}
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found for workflow: ${workflowId}`)
|
||||
return createErrorResponse('Workflow not found in marketplace', 404)
|
||||
}
|
||||
|
||||
// Transform response if state was requested
|
||||
const responseData =
|
||||
includeState && 'state' in marketplaceEntry
|
||||
? {
|
||||
...marketplaceEntry,
|
||||
workflowState: marketplaceEntry.state,
|
||||
state: undefined,
|
||||
}
|
||||
: marketplaceEntry
|
||||
|
||||
logger.info(`[${requestId}] Retrieved marketplace data for workflow: ${workflowId}`)
|
||||
return createSuccessResponse(responseData)
|
||||
}
|
||||
|
||||
// Handle single marketplace entry request (by marketplace ID)
|
||||
if (marketplaceId) {
|
||||
let marketplaceEntry
|
||||
|
||||
if (includeState) {
|
||||
// Query with state included
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
state: marketplace.state,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, marketplaceId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
} else {
|
||||
// Query without state
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, marketplaceId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
}
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found with ID: ${marketplaceId}`)
|
||||
return createErrorResponse('Marketplace entry not found', 404)
|
||||
}
|
||||
|
||||
// Transform response if state was requested
|
||||
const responseData =
|
||||
includeState && 'state' in marketplaceEntry
|
||||
? {
|
||||
...marketplaceEntry,
|
||||
workflowState: marketplaceEntry.state,
|
||||
state: undefined,
|
||||
}
|
||||
: marketplaceEntry
|
||||
|
||||
logger.info(`[${requestId}] Retrieved marketplace entry: ${marketplaceId}`)
|
||||
return createSuccessResponse(responseData)
|
||||
}
|
||||
|
||||
// Handle featured/collection requests
|
||||
const result: {
|
||||
popular: any[]
|
||||
recent: any[]
|
||||
byCategory: Record<string, any[]>
|
||||
} = {
|
||||
popular: [],
|
||||
recent: [],
|
||||
byCategory: {},
|
||||
}
|
||||
|
||||
// Define common fields to select
|
||||
const baseFields = {
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
}
|
||||
|
||||
// Add state if requested
|
||||
const selectFields = includeState ? { ...baseFields, state: marketplace.state } : baseFields
|
||||
|
||||
// Determine which sections to fetch
|
||||
const sections = sectionParam ? sectionParam.split(',') : ['popular', 'recent', 'byCategory']
|
||||
|
||||
// Get popular items if requested
|
||||
if (sections.includes('popular')) {
|
||||
result.popular = await db
|
||||
.select(selectFields)
|
||||
.from(marketplace)
|
||||
.orderBy(desc(marketplace.views))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
// Get recent items if requested
|
||||
if (sections.includes('recent')) {
|
||||
result.recent = await db
|
||||
.select(selectFields)
|
||||
.from(marketplace)
|
||||
.orderBy(desc(marketplace.createdAt))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
// Get categories if requested
|
||||
if (
|
||||
sections.includes('byCategory') ||
|
||||
categoryParam ||
|
||||
sections.some((s) => CATEGORIES.some((c) => c.value === s))
|
||||
) {
|
||||
// Identify all requested categories
|
||||
const requestedCategories = new Set<string>()
|
||||
|
||||
// Add explicitly requested category
|
||||
if (categoryParam) {
|
||||
requestedCategories.add(categoryParam)
|
||||
}
|
||||
|
||||
// Add categories from sections parameter
|
||||
sections.forEach((section) => {
|
||||
if (CATEGORIES.some((c) => c.value === section)) {
|
||||
requestedCategories.add(section)
|
||||
}
|
||||
})
|
||||
|
||||
// Include byCategory section contents if requested
|
||||
if (sections.includes('byCategory')) {
|
||||
CATEGORIES.forEach((c) => requestedCategories.add(c.value))
|
||||
}
|
||||
|
||||
// Log what we're fetching
|
||||
const categoriesToFetch = Array.from(requestedCategories)
|
||||
logger.info(`[${requestId}] Fetching specific categories: ${categoriesToFetch.join(', ')}`)
|
||||
|
||||
// Process each requested category
|
||||
await Promise.all(
|
||||
categoriesToFetch.map(async (categoryValue) => {
|
||||
const categoryItems = await db
|
||||
.select(selectFields)
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.category, categoryValue))
|
||||
.orderBy(desc(marketplace.views))
|
||||
.limit(limit)
|
||||
|
||||
// Always add the category to the result, even if empty
|
||||
result.byCategory[categoryValue] = categoryItems
|
||||
logger.info(
|
||||
`[${requestId}] Category ${categoryValue}: found ${categoryItems.length} items`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Transform the data if state was included to match the expected format
|
||||
if (includeState) {
|
||||
const transformSection = (section: any[]) => {
|
||||
return section.map((item) => {
|
||||
if ('state' in item) {
|
||||
// Create a new object without the state field, but with workflowState
|
||||
const { state, ...rest } = item
|
||||
return {
|
||||
...rest,
|
||||
workflowState: state,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
if (result.popular.length > 0) {
|
||||
result.popular = transformSection(result.popular)
|
||||
}
|
||||
|
||||
if (result.recent.length > 0) {
|
||||
result.recent = transformSection(result.recent)
|
||||
}
|
||||
|
||||
Object.keys(result.byCategory).forEach((category) => {
|
||||
if (result.byCategory[category].length > 0) {
|
||||
result.byCategory[category] = transformSection(result.byCategory[category])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetched marketplace items${includeState ? ' with state' : ''}`)
|
||||
return NextResponse.json(result)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching marketplace items`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for incrementing view counts
|
||||
*
|
||||
* Request body:
|
||||
* - id: Marketplace entry ID to increment view count for
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id } = body
|
||||
|
||||
if (!id) {
|
||||
return createErrorResponse('Marketplace ID is required', 400)
|
||||
}
|
||||
|
||||
// Find the marketplace entry
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: marketplace.id,
|
||||
})
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`)
|
||||
return createErrorResponse('Marketplace entry not found', 404)
|
||||
}
|
||||
|
||||
// Increment the view count
|
||||
await db
|
||||
.update(marketplace)
|
||||
.set({
|
||||
views: sql`${marketplace.views} + 1`,
|
||||
})
|
||||
.where(eq(marketplace.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error incrementing view count`, error)
|
||||
return createErrorResponse(`Failed to track view: ${error.message}`, 500)
|
||||
}
|
||||
}
|
||||
65
apps/sim/app/api/templates/[id]/route.ts
Normal file
65
apps/sim/app/api/templates/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { templates } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// GET /api/templates/[id] - Retrieve a single template by ID
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Fetching template: ${id}`)
|
||||
|
||||
// Fetch the template by ID
|
||||
const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const template = result[0]
|
||||
|
||||
// Increment the view count
|
||||
try {
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
|
||||
} catch (viewError) {
|
||||
// Log the error but don't fail the request
|
||||
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved template: ${id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...template,
|
||||
views: template.views + 1, // Return the incremented view count
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
173
apps/sim/app/api/templates/[id]/star/route.ts
Normal file
173
apps/sim/app/api/templates/[id]/star/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { templateStars, templates } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplateStarAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// GET /api/templates/[id]/star - Check if user has starred this template
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}`
|
||||
)
|
||||
|
||||
// Check if the user has starred this template
|
||||
const starRecord = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
const isStarred = starRecord.length > 0
|
||||
|
||||
logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`)
|
||||
|
||||
return NextResponse.json({ data: { isStarred } })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error checking star status for template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/templates/[id]/star - Add a star to the template
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`)
|
||||
|
||||
// Verify the template exists
|
||||
const templateExists = await db
|
||||
.select({ id: templates.id })
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (templateExists.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has already starred this template
|
||||
const existingStar = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (existingStar.length > 0) {
|
||||
logger.info(`[${requestId}] Template already starred: ${id}`)
|
||||
return NextResponse.json({ message: 'Template already starred' }, { status: 200 })
|
||||
}
|
||||
|
||||
// Use a transaction to ensure consistency
|
||||
await db.transaction(async (tx) => {
|
||||
// Add the star record
|
||||
await tx.insert(templateStars).values({
|
||||
id: uuidv4(),
|
||||
userId: session.user.id,
|
||||
templateId: id,
|
||||
starredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Increment the star count
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({
|
||||
stars: sql`${templates.stars} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Successfully starred template: ${id}`)
|
||||
return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
// Handle unique constraint violations gracefully
|
||||
if (error.code === '23505') {
|
||||
logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`)
|
||||
return NextResponse.json({ message: 'Template already starred' }, { status: 200 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error starring template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/templates/[id]/star - Remove a star from the template
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`)
|
||||
|
||||
// Check if the star exists
|
||||
const existingStar = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (existingStar.length === 0) {
|
||||
logger.info(`[${requestId}] No star found to remove for template: ${id}`)
|
||||
return NextResponse.json({ message: 'Template not starred' }, { status: 200 })
|
||||
}
|
||||
|
||||
// Use a transaction to ensure consistency
|
||||
await db.transaction(async (tx) => {
|
||||
// Remove the star record
|
||||
await tx
|
||||
.delete(templateStars)
|
||||
.where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
|
||||
|
||||
// Decrement the star count (prevent negative values)
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({
|
||||
stars: sql`GREATEST(${templates.stars} - 1, 0)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Successfully unstarred template: ${id}`)
|
||||
return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error unstarring template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
202
apps/sim/app/api/templates/[id]/use/route.ts
Normal file
202
apps/sim/app/api/templates/[id]/use/route.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { templates, workflow, workflowBlocks, workflowEdges } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplateUseAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// POST /api/templates/[id]/use - Use a template (increment views and create workflow)
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get workspace ID from request body
|
||||
const body = await request.json()
|
||||
const { workspaceId } = body
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}`
|
||||
)
|
||||
|
||||
// Get the template with its data
|
||||
const template = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
state: templates.state,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (template.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const templateData = template[0]
|
||||
|
||||
// Create a new workflow ID
|
||||
const newWorkflowId = uuidv4()
|
||||
|
||||
// Use a transaction to ensure consistency
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Increment the template views
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Create a new workflow from the template
|
||||
const newWorkflow = await tx
|
||||
.insert(workflow)
|
||||
.values({
|
||||
id: newWorkflowId,
|
||||
workspaceId: workspaceId,
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
state: templateData.state,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastSynced: now,
|
||||
})
|
||||
.returning({ id: workflow.id })
|
||||
|
||||
// Create workflow_blocks entries from the template state
|
||||
const templateState = templateData.state as any
|
||||
if (templateState?.blocks) {
|
||||
// Create a mapping from old block IDs to new block IDs for reference updates
|
||||
const blockIdMap = new Map<string, string>()
|
||||
|
||||
const blockEntries = Object.values(templateState.blocks).map((block: any) => {
|
||||
const newBlockId = uuidv4()
|
||||
blockIdMap.set(block.id, newBlockId)
|
||||
|
||||
return {
|
||||
id: newBlockId,
|
||||
workflowId: newWorkflowId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
positionX: block.position?.x?.toString() || '0',
|
||||
positionY: block.position?.y?.toString() || '0',
|
||||
enabled: block.enabled !== false,
|
||||
horizontalHandles: block.horizontalHandles !== false,
|
||||
isWide: block.isWide || false,
|
||||
advancedMode: block.advancedMode || false,
|
||||
height: block.height?.toString() || '0',
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
data: block.data || {},
|
||||
parentId: block.parentId ? blockIdMap.get(block.parentId) || null : null,
|
||||
extent: block.extent || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
// Create edge entries with new IDs
|
||||
const edgeEntries = (templateState.edges || []).map((edge: any) => ({
|
||||
id: uuidv4(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMap.get(edge.source) || edge.source,
|
||||
targetBlockId: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: edge.sourceHandle || null,
|
||||
targetHandle: edge.targetHandle || null,
|
||||
createdAt: now,
|
||||
}))
|
||||
|
||||
// Update the workflow state with new block IDs
|
||||
const updatedState = { ...templateState }
|
||||
if (updatedState.blocks) {
|
||||
const newBlocks: any = {}
|
||||
Object.entries(updatedState.blocks).forEach(([oldId, blockData]: [string, any]) => {
|
||||
const newId = blockIdMap.get(oldId)
|
||||
if (newId) {
|
||||
newBlocks[newId] = {
|
||||
...blockData,
|
||||
id: newId,
|
||||
}
|
||||
}
|
||||
})
|
||||
updatedState.blocks = newBlocks
|
||||
}
|
||||
|
||||
// Update edges to use new block IDs
|
||||
if (updatedState.edges) {
|
||||
updatedState.edges = updatedState.edges.map((edge: any) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update the workflow with the corrected state
|
||||
await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
// Insert blocks and edges
|
||||
if (blockEntries.length > 0) {
|
||||
await tx.insert(workflowBlocks).values(blockEntries)
|
||||
}
|
||||
if (edgeEntries.length > 0) {
|
||||
await tx.insert(workflowEdges).values(edgeEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return newWorkflow[0]
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}, database returned: ${result.id}`
|
||||
)
|
||||
|
||||
// Verify the workflow was actually created
|
||||
const verifyWorkflow = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (verifyWorkflow.length === 0) {
|
||||
logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`)
|
||||
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Template used successfully',
|
||||
workflowId: newWorkflowId,
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error using template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
260
apps/sim/app/api/templates/route.ts
Normal file
260
apps/sim/app/api/templates/route.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { templateStars, templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplatesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// Function to sanitize sensitive data from workflow state
|
||||
function sanitizeWorkflowState(state: any): any {
|
||||
const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone
|
||||
|
||||
if (sanitizedState.blocks) {
|
||||
Object.values(sanitizedState.blocks).forEach((block: any) => {
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
|
||||
// Clear OAuth credentials and API keys using regex patterns
|
||||
if (
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
|
||||
subBlock.type || ''
|
||||
) ||
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
|
||||
subBlock.value || ''
|
||||
)
|
||||
) {
|
||||
subBlock.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Also clear from data field if present
|
||||
if (block.data) {
|
||||
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
|
||||
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
|
||||
block.data[key] = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sanitizedState
|
||||
}
|
||||
|
||||
// Schema for creating a template
|
||||
const CreateTemplateSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
author: z
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
state: z.object({
|
||||
blocks: z.record(z.any()),
|
||||
edges: z.array(z.any()),
|
||||
loops: z.record(z.any()),
|
||||
parallels: z.record(z.any()),
|
||||
}),
|
||||
})
|
||||
|
||||
// Schema for query parameters
|
||||
const QueryParamsSchema = z.object({
|
||||
category: z.string().optional(),
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/templates - Retrieve templates
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized templates access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
|
||||
|
||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||
|
||||
// Build query conditions
|
||||
const conditions = []
|
||||
|
||||
// Apply category filter if provided
|
||||
if (params.category) {
|
||||
conditions.push(eq(templates.category, params.category))
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
conditions.push(
|
||||
or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm))
|
||||
)
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Apply ordering, limit, and offset with star information
|
||||
const results = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.limit(params.limit)
|
||||
.offset(params.offset)
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(templates)
|
||||
.where(whereCondition)
|
||||
|
||||
const total = totalCount[0]?.count || 0
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved ${results.length} templates`)
|
||||
|
||||
return NextResponse.json({
|
||||
data: results,
|
||||
pagination: {
|
||||
total,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
page: Math.floor(params.offset / params.limit) + 1,
|
||||
totalPages: Math.ceil(total / params.limit),
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid query parameters', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error fetching templates`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/templates - Create a new template
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = CreateTemplateSchema.parse(body)
|
||||
|
||||
logger.debug(`[${requestId}] Creating template:`, {
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
workflowId: data.workflowId,
|
||||
})
|
||||
|
||||
// Verify the workflow exists and belongs to the user
|
||||
const workflowExists = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, data.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (workflowExists.length === 0) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create the template
|
||||
const templateId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
// Sanitize the workflow state to remove sensitive credentials
|
||||
const sanitizedState = sanitizeWorkflowState(data.state)
|
||||
|
||||
const newTemplate = {
|
||||
id: templateId,
|
||||
workflowId: data.workflowId,
|
||||
userId: session.user.id,
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
author: data.author,
|
||||
views: 0,
|
||||
stars: 0,
|
||||
color: data.color,
|
||||
icon: data.icon,
|
||||
category: data.category,
|
||||
state: sanitizedState,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(templates).values(newTemplate)
|
||||
|
||||
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: templateId,
|
||||
message: 'Template created successfully',
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid template data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating template`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -79,23 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
variablesRecord[variable.id] = variable
|
||||
})
|
||||
|
||||
// Get existing variables to merge with the incoming ones
|
||||
const existingVariables = (workflowRecord[0].variables as Record<string, Variable>) || {}
|
||||
|
||||
// Create a timestamp based on the current request
|
||||
|
||||
// Merge variables: Keep existing ones and update/add new ones
|
||||
// This prevents variables from being deleted during race conditions
|
||||
const mergedVariables = {
|
||||
...existingVariables,
|
||||
...variablesRecord,
|
||||
}
|
||||
// Replace variables completely with the incoming ones
|
||||
// The frontend is the source of truth for what variables should exist
|
||||
const updatedVariables = variablesRecord
|
||||
|
||||
// Update workflow with variables
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables: mergedVariables,
|
||||
variables: updatedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'crypto'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, workflow, workflowBlocks, workspace, workspaceMember } from '@/db/schema'
|
||||
|
||||
@@ -18,6 +19,7 @@ export async function GET() {
|
||||
.select({
|
||||
workspace: workspace,
|
||||
role: workspaceMember.role,
|
||||
membershipId: workspaceMember.id,
|
||||
})
|
||||
.from(workspaceMember)
|
||||
.innerJoin(workspace, eq(workspaceMember.workspaceId, workspace.id))
|
||||
@@ -37,13 +39,25 @@ export async function GET() {
|
||||
// If user has workspaces but might have orphaned workflows, migrate them
|
||||
await ensureWorkflowsHaveWorkspace(session.user.id, memberWorkspaces[0].workspace.id)
|
||||
|
||||
// Format the response
|
||||
const workspaces = memberWorkspaces.map(({ workspace: workspaceDetails, role }) => ({
|
||||
// Get permissions for each workspace and format the response
|
||||
const workspacesWithPermissions = await Promise.all(
|
||||
memberWorkspaces.map(async ({ workspace: workspaceDetails, role, membershipId }) => {
|
||||
const userPermissions = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceDetails.id
|
||||
)
|
||||
|
||||
return {
|
||||
...workspaceDetails,
|
||||
role,
|
||||
}))
|
||||
membershipId,
|
||||
permissions: userPermissions,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ workspaces })
|
||||
return NextResponse.json({ workspaces: workspacesWithPermissions })
|
||||
}
|
||||
|
||||
// POST /api/workspaces - Create a new workspace
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Z-index fixes for workflow components */
|
||||
/* ==========================================================================
|
||||
WORKFLOW COMPONENT Z-INDEX FIXES
|
||||
========================================================================== */
|
||||
.workflow-container .react-flow__edges {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
@@ -24,81 +26,131 @@
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
THEME SYSTEM - CSS CUSTOM PROPERTIES
|
||||
========================================================================== */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light Mode Theme */
|
||||
:root,
|
||||
.light {
|
||||
/* Core Colors */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
/* Card Colors */
|
||||
--card: 0 0% 99.2%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
/* Popover Colors */
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
/* Primary Colors */
|
||||
--primary: 0 0% 11.2%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
/* Secondary Colors */
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
/* Muted Colors */
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
/* Accent Colors */
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 11.2%;
|
||||
|
||||
/* Destructive Colors */
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
/* Border & Input Colors */
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
/* Border Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Scrollbar Custom Properties */
|
||||
/* Scrollbar Properties */
|
||||
--scrollbar-track: 0 0% 85%;
|
||||
--scrollbar-thumb: 0 0% 65%;
|
||||
--scrollbar-thumb-hover: 0 0% 55%;
|
||||
--scrollbar-size: 8px;
|
||||
|
||||
/* Workflow Properties */
|
||||
--workflow-background: 0 0% 100%;
|
||||
--workflow-dots: 0 0% 94.5%;
|
||||
--card-background: 0 0% 99.2%;
|
||||
--card-border: 0 0% 89.8%;
|
||||
--card-text: 0 0% 3.9%;
|
||||
--card-hover: 0 0% 96.1%;
|
||||
|
||||
/* Base Component Properties */
|
||||
--base-muted-foreground: #737373;
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
/* Core Colors */
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
/* Card Colors */
|
||||
--card: 0 0% 9.0%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
/* Popover Colors */
|
||||
--popover: 0 0% 9.0%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
/* Primary Colors */
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
/* Secondary Colors */
|
||||
--secondary: 0 0% 12.0%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
/* Muted Colors */
|
||||
--muted: 0 0% 17.5%;
|
||||
--muted-foreground: 0 0% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
/* Accent Colors */
|
||||
--accent: 0 0% 17.5%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
/* Destructive Colors */
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
/* Border & Input Colors */
|
||||
--border: 0 0% 22.7%;
|
||||
--input: 0 0% 22.7%;
|
||||
--ring: 0 0% 83.9%;
|
||||
|
||||
/* Dark Mode Scrollbar Custom Properties */
|
||||
--scrollbar-track: 217.2 32.6% 17.5%;
|
||||
--scrollbar-thumb: 217.2 32.6% 30%;
|
||||
--scrollbar-thumb-hover: 217.2 32.6% 40%;
|
||||
/* Scrollbar Properties */
|
||||
--scrollbar-track: 0 0% 17.5%;
|
||||
--scrollbar-thumb: 0 0% 30%;
|
||||
--scrollbar-thumb-hover: 0 0% 40%;
|
||||
|
||||
/* Workflow Properties */
|
||||
--workflow-background: 0 0% 3.9%;
|
||||
--workflow-dots: 0 0% 7.5%;
|
||||
--card-background: 0 0% 9.0%;
|
||||
--card-border: 0 0% 22.7%;
|
||||
--card-text: 0 0% 98%;
|
||||
--card-hover: 0 0% 12.0%;
|
||||
|
||||
/* Base Component Properties */
|
||||
--base-muted-foreground: #a3a3a3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
BASE STYLES
|
||||
========================================================================== */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@@ -143,28 +195,137 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Animations */
|
||||
/* ==========================================================================
|
||||
TYPOGRAPHY
|
||||
========================================================================== */
|
||||
.font-geist-sans {
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
.font-geist-mono {
|
||||
font-family: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
PANEL STYLES
|
||||
========================================================================== */
|
||||
.panel-tab-base {
|
||||
color: var(--base-muted-foreground);
|
||||
}
|
||||
|
||||
.panel-tab-active {
|
||||
/* Light Mode Panel Tab Active */
|
||||
background-color: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Dark Mode Panel Tab Active */
|
||||
.dark .panel-tab-active {
|
||||
background-color: #1f1f1f;
|
||||
color: #ffffff;
|
||||
border-color: #424242;
|
||||
}
|
||||
|
||||
.panel-tab-inactive:hover {
|
||||
background-color: hsl(var(--secondary));
|
||||
color: hsl(var(--card-foreground));
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DARK MODE OVERRIDES
|
||||
========================================================================== */
|
||||
.dark .error-badge {
|
||||
background-color: hsl(0, 70%, 20%) !important;
|
||||
/* Darker red background for dark mode */
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
/* Pure white text for better contrast */
|
||||
}
|
||||
|
||||
.dark .bg-red-500 {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
BROWSER INPUT OVERRIDES
|
||||
========================================================================== */
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="search"]::-moz-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Microsoft Edge */
|
||||
input[type="search"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
LAYOUT UTILITIES
|
||||
========================================================================== */
|
||||
.main-content-overlay {
|
||||
z-index: 40;
|
||||
/* Higher z-index to appear above content */
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ANIMATIONS & UTILITIES
|
||||
========================================================================== */
|
||||
@layer utilities {
|
||||
/* Animation containment to avoid layout shifts */
|
||||
/* Animation Performance */
|
||||
.animation-container {
|
||||
contain: paint layout style;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
/* Scrollbar Utilities */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px hsl(var(--border));
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
}
|
||||
.scrollbar-hide {
|
||||
/* For Webkit browsers (Chrome, Safari, Edge) */
|
||||
-webkit-scrollbar: none;
|
||||
-webkit-scrollbar-width: none;
|
||||
-webkit-scrollbar-track: transparent;
|
||||
-webkit-scrollbar-thumb: transparent;
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
/* For Internet Explorer and Edge Legacy */
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar-track {
|
||||
display: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@@ -175,16 +336,6 @@
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Utility Classes */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.animate-orbit {
|
||||
animation: orbit calc(var(--duration, 2) * 1s) linear infinite;
|
||||
}
|
||||
@@ -197,7 +348,58 @@
|
||||
animation: marquee-vertical var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
/* Code Editor Streaming Effect */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Special Effects */
|
||||
.streaming-effect {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.streaming-effect::after {
|
||||
content: "";
|
||||
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%
|
||||
);
|
||||
animation: code-shimmer 1.5s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .streaming-effect::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.loading-placeholder::placeholder {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
========================================================================== */
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px hsl(var(--border));
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
@@ -240,32 +442,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-effect {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.streaming-effect::after {
|
||||
content: "";
|
||||
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%
|
||||
);
|
||||
animation: code-shimmer 1.5s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .streaming-effect::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -276,37 +452,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode error badge styling */
|
||||
.dark .error-badge {
|
||||
background-color: hsl(0, 70%, 20%) !important;
|
||||
/* Darker red background for dark mode */
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
/* Pure white text for better contrast */
|
||||
}
|
||||
|
||||
/* Input Overrides */
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="search"]::-moz-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Microsoft Edge */
|
||||
input[type="search"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Code Prompt Bar Placeholder Animation */
|
||||
@keyframes placeholder-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -317,27 +462,4 @@ input[type="search"]::-ms-clear {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-placeholder::placeholder {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Dark mode error badge styling */
|
||||
.dark .bg-red-500 {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
|
||||
/* Font Variables */
|
||||
.font-geist-sans {
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
.font-geist-mono {
|
||||
font-family: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
/* Sidebar overlay styles */
|
||||
.main-content-overlay {
|
||||
z-index: 40;
|
||||
/* Higher z-index to appear above content */
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header'
|
||||
import { ChunkTableSkeleton } from '../../../components/skeletons/table-skeleton'
|
||||
|
||||
@@ -18,11 +17,8 @@ export function DocumentLoading({
|
||||
knowledgeBaseName,
|
||||
documentName,
|
||||
}: DocumentLoadingProps) {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
@@ -42,9 +38,7 @@ export function DocumentLoading({
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Header with Breadcrumbs */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
@@ -78,7 +72,7 @@ export function DocumentLoading({
|
||||
</div>
|
||||
|
||||
{/* Table container */}
|
||||
<ChunkTableSkeleton isSidebarCollapsed={isSidebarCollapsed} rowCount={8} />
|
||||
<ChunkTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
|
||||
import { SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components/search-input/search-input'
|
||||
import { useDocumentChunks } from '@/hooks/use-knowledge'
|
||||
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header'
|
||||
import { CreateChunkModal } from './components/create-chunk-modal/create-chunk-modal'
|
||||
import { DeleteChunkModal } from './components/delete-chunk-modal/delete-chunk-modal'
|
||||
@@ -45,15 +44,10 @@ export function Document({
|
||||
knowledgeBaseName,
|
||||
documentName,
|
||||
}: DocumentProps) {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const { getCachedKnowledgeBase, getCachedDocuments } = useKnowledgeStore()
|
||||
const { workspaceId } = useParams()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10)
|
||||
|
||||
const {
|
||||
@@ -247,7 +241,7 @@ export function Document({
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedChunks(new Set(paginatedChunks.map((chunk) => chunk.id)))
|
||||
setSelectedChunks(new Set(paginatedChunks.map((chunk: ChunkData) => chunk.id)))
|
||||
} else {
|
||||
setSelectedChunks(new Set())
|
||||
}
|
||||
@@ -362,9 +356,7 @@ export function Document({
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<KnowledgeHeader breadcrumbs={errorBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
@@ -382,9 +374,7 @@ export function Document({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Fixed Header with Breadcrumbs */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
@@ -463,7 +453,7 @@ export function Document({
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
||||
<col className='w-[55%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
@@ -509,7 +499,7 @@ export function Document({
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
||||
<col className='w-[55%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
@@ -602,7 +592,7 @@ export function Document({
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
paginatedChunks.map((chunk) => (
|
||||
paginatedChunks.map((chunk: ChunkData) => (
|
||||
<tr
|
||||
key={chunk.id}
|
||||
className='cursor-pointer border-b transition-colors hover:bg-accent/30'
|
||||
|
||||
@@ -36,7 +36,6 @@ import { PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/component
|
||||
import { SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components/search-input/search-input'
|
||||
import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
|
||||
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from '../components/knowledge-header/knowledge-header'
|
||||
import { KnowledgeBaseLoading } from './components/knowledge-base-loading/knowledge-base-loading'
|
||||
import { UploadModal } from './components/upload-modal/upload-modal'
|
||||
@@ -120,7 +119,6 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const { removeKnowledgeBase } = useKnowledgeStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -158,9 +156,6 @@ export function KnowledgeBase({
|
||||
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
||||
})
|
||||
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
|
||||
@@ -631,9 +626,7 @@ export function KnowledgeBase({
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<KnowledgeHeader breadcrumbs={errorBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
@@ -651,9 +644,7 @@ export function KnowledgeBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Fixed Header with Breadcrumbs */}
|
||||
<KnowledgeHeader
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -711,11 +702,11 @@ export function KnowledgeBase({
|
||||
<table className='w-full min-w-[700px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
||||
<col className='w-[24%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
||||
<col className='w-[16%]' />
|
||||
<col className='w-[12%]' />
|
||||
<col className='w-[14%]' />
|
||||
</colgroup>
|
||||
@@ -764,11 +755,11 @@ export function KnowledgeBase({
|
||||
<table className='w-full min-w-[700px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
||||
<col className='w-[24%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
||||
<col className='w-[16%]' />
|
||||
<col className='w-[12%]' />
|
||||
<col className='w-[14%]' />
|
||||
</colgroup>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header'
|
||||
import { DocumentTableSkeleton } from '../../../components/skeletons/table-skeleton'
|
||||
|
||||
@@ -12,11 +11,8 @@ interface KnowledgeBaseLoadingProps {
|
||||
}
|
||||
|
||||
export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
@@ -31,9 +27,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Fixed Header with Breadcrumbs */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
@@ -70,7 +64,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
|
||||
</div>
|
||||
|
||||
{/* Table container */}
|
||||
<DocumentTableSkeleton isSidebarCollapsed={isSidebarCollapsed} rowCount={8} />
|
||||
<DocumentTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useMemo, useState } from 'react'
|
||||
import { LibraryBig, Plus } from 'lucide-react'
|
||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { BaseOverview } from './components/base-overview/base-overview'
|
||||
import { CreateModal } from './components/create-modal/create-modal'
|
||||
import { EmptyStateCard } from './components/empty-state-card/empty-state-card'
|
||||
@@ -18,13 +17,9 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const { knowledgeBases, isLoading, error, addKnowledgeBase, refreshList } =
|
||||
useKnowledgeBasesList()
|
||||
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
|
||||
@@ -56,9 +51,7 @@ export function Knowledge() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-screen flex-col pl-64'>
|
||||
{/* Header */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from './components/knowledge-header/knowledge-header'
|
||||
import { KnowledgeBaseCardSkeletonGrid } from './components/skeletons/knowledge-base-card-skeleton'
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-screen flex-col pl-64'>
|
||||
{/* Header */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Info, Loader2 } from 'lucide-react'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { ControlBar } from './components/control-bar/control-bar'
|
||||
import { Filters } from './components/filters/filters'
|
||||
import { Sidebar } from './components/sidebar/sidebar'
|
||||
@@ -55,9 +54,6 @@ export default function Logs() {
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const handleLogClick = (log: WorkflowLog) => {
|
||||
setSelectedLog(log)
|
||||
@@ -291,9 +287,7 @@ export default function Logs() {
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
{/* Add the animation styles */}
|
||||
<style jsx global>
|
||||
{selectedRowAnimation}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
interface ControlBarProps {
|
||||
setSearchQuery: (query: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Control bar for marketplace page - includes search functionality
|
||||
*/
|
||||
export function ControlBar({ setSearchQuery }: ControlBarProps) {
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(localSearchQuery, 300)
|
||||
|
||||
// Update parent component when debounced search query changes
|
||||
useEffect(() => {
|
||||
setSearchQuery(debouncedSearchQuery)
|
||||
}, [debouncedSearchQuery, setSearchQuery])
|
||||
|
||||
return (
|
||||
<div className='flex h-16 w-full items-center justify-between border-b bg-background px-6 transition-all duration-300'>
|
||||
{/* Left Section - Search */}
|
||||
<div className='relative w-[400px]'>
|
||||
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
<Input
|
||||
type='search'
|
||||
placeholder='Search workflows...'
|
||||
className='h-9 pl-10'
|
||||
value={localSearchQuery}
|
||||
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle Section - Reserved for future use */}
|
||||
<div className='flex-1' />
|
||||
|
||||
{/* Right Section - Reserved for future use */}
|
||||
<div className='flex items-center gap-3' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* ErrorMessageProps interface - defines the properties for the ErrorMessage component
|
||||
* @property {string | null} message - The error message to display, or null if no error
|
||||
*/
|
||||
interface ErrorMessageProps {
|
||||
message: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorMessage component - Displays an error message with animation
|
||||
* Only renders when a message is provided, otherwise returns null
|
||||
* Uses Framer Motion for smooth entrance animation
|
||||
*/
|
||||
export function ErrorMessage({ message }: ErrorMessageProps) {
|
||||
// Don't render anything if there's no message
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='mb-8 rounded-md border border-red-200 bg-red-50 p-4 text-red-700'
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<p className='flex items-center'>
|
||||
<AlertCircle className='mr-2 h-4 w-4' />
|
||||
{message}
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* SectionProps interface - defines the properties for the Section component
|
||||
* @property {string} title - The heading text for the section
|
||||
* @property {string} id - The ID for the section (used for scroll targeting)
|
||||
* @property {ReactNode} children - The content to be rendered inside the section
|
||||
*/
|
||||
interface SectionProps {
|
||||
title: string
|
||||
id: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Section component - Renders a section with a title and content
|
||||
* Used to organize different categories of workflows in the marketplace
|
||||
* Implements forwardRef to allow parent components to access the DOM node for scrolling
|
||||
*/
|
||||
export const Section = forwardRef<HTMLDivElement, SectionProps>(({ title, id, children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} id={id} className='mb-12 scroll-mt-14'>
|
||||
<h2 className='mb-6 font-medium text-lg capitalize'>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Section.displayName = 'Section'
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, Star } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CATEGORIES, getCategoryIcon, getCategoryLabel } from '../../constants/categories'
|
||||
|
||||
export type MarketplaceCategory = 'popular' | 'programming' | 'marketing' | 'all'
|
||||
|
||||
interface ToolbarProps {
|
||||
scrollToSection: (sectionId: string) => void
|
||||
activeSection: string | null
|
||||
}
|
||||
|
||||
// Map of special section icons
|
||||
const specialIcons: Record<string, React.ReactNode> = {
|
||||
popular: <Star className='mr-2 h-4 w-4' />,
|
||||
recent: <Clock className='mr-2 h-4 w-4' />,
|
||||
}
|
||||
|
||||
export function Toolbar({ scrollToSection, activeSection }: ToolbarProps) {
|
||||
const [categories, setCategories] = useState<string[]>([])
|
||||
|
||||
// Set categories including special sections
|
||||
useEffect(() => {
|
||||
// Start with special sections like 'popular' and 'recent'
|
||||
const specialSections = ['popular', 'recent']
|
||||
|
||||
// Add categories from centralized definitions
|
||||
const categoryValues = CATEGORIES.map((cat) => cat.value)
|
||||
|
||||
// Put special sections first, then regular categories
|
||||
const allCategories = [...specialSections, ...categoryValues]
|
||||
|
||||
setCategories(allCategories)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='h-full w-60 overflow-auto border-r p-4'>
|
||||
<h2 className='mb-4 pl-2 font-medium text-sm'>Categories</h2>
|
||||
<nav className='space-y-1'>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant='ghost'
|
||||
className={`w-full justify-start px-2 py-2 font-medium text-muted-foreground text-sm capitalize transition-colors hover:text-foreground ${
|
||||
activeSection === category ? 'bg-accent text-foreground' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => scrollToSection(category)}
|
||||
>
|
||||
{specialIcons[category] || getCategoryIcon(category)}
|
||||
{category === 'popular'
|
||||
? 'Popular'
|
||||
: category === 'recent'
|
||||
? 'Recent'
|
||||
: getCategoryLabel(category)}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* WorkflowCardSkeleton - Loading placeholder component for workflow cards
|
||||
* Displays a skeleton UI while workflow data is being fetched
|
||||
* Maintains the same structure as WorkflowCard for consistent layout during loading
|
||||
*/
|
||||
export function WorkflowCardSkeleton() {
|
||||
return (
|
||||
<Card className='flex h-full flex-col overflow-hidden'>
|
||||
{/* Thumbnail area skeleton */}
|
||||
<div className='relative h-40 overflow-hidden bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-700'>
|
||||
<Skeleton className='h-full w-full' />
|
||||
</div>
|
||||
<div className='flex flex-grow flex-col'>
|
||||
{/* Title skeleton */}
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<Skeleton className='h-4 w-3/4' />
|
||||
</CardHeader>
|
||||
{/* Description skeleton */}
|
||||
<CardContent className='flex flex-grow flex-col p-4 pt-0 pb-2'>
|
||||
<Skeleton className='mb-1 h-3 w-full' />
|
||||
<Skeleton className='h-3 w-4/5' />
|
||||
</CardContent>
|
||||
{/* Footer with author and views skeletons */}
|
||||
<CardFooter className='mt-auto flex items-center justify-between p-4 pt-2'>
|
||||
<Skeleton className='h-3 w-1/4' />
|
||||
<Skeleton className='h-3 w-10' />
|
||||
</CardFooter>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { Workflow } from '../marketplace'
|
||||
|
||||
/**
|
||||
* WorkflowCardProps interface - defines the properties for the WorkflowCard component
|
||||
* @property {Workflow} workflow - The workflow data to display
|
||||
* @property {number} index - The index of the workflow in the list
|
||||
* @property {Function} onHover - Optional callback function triggered when card is hovered
|
||||
*/
|
||||
interface WorkflowCardProps {
|
||||
workflow: Workflow
|
||||
index: number
|
||||
onHover?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowCard component - Displays a workflow in a card format
|
||||
* Shows either a workflow preview, thumbnail image, or fallback text
|
||||
* State is now pre-loaded in most cases, fallback to load on hover if needed
|
||||
*/
|
||||
export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
|
||||
const [isPreviewReady, setIsPreviewReady] = useState(!!workflow.workflowState)
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { createWorkflow } = useWorkflowRegistry()
|
||||
|
||||
// When workflow state becomes available, update preview ready state
|
||||
useEffect(() => {
|
||||
if (workflow.workflowState && !isPreviewReady) {
|
||||
setIsPreviewReady(true)
|
||||
}
|
||||
}, [workflow.workflowState, isPreviewReady])
|
||||
|
||||
/**
|
||||
* Handle mouse enter event
|
||||
* Sets hover state and triggers onHover callback to load workflow state if needed
|
||||
*/
|
||||
const handleMouseEnter = () => {
|
||||
if (onHover && !workflow.workflowState) {
|
||||
onHover(workflow.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workflow card click - track views and import workflow
|
||||
*/
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
// Track view
|
||||
await fetch('/api/marketplace/workflows', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id: workflow.id }),
|
||||
})
|
||||
|
||||
// Create a local copy of the marketplace workflow
|
||||
if (workflow.workflowState) {
|
||||
const newWorkflowId = await createWorkflow({
|
||||
name: `${workflow.name} (Copy)`,
|
||||
description: workflow.description,
|
||||
marketplaceId: workflow.id,
|
||||
marketplaceState: workflow.workflowState,
|
||||
})
|
||||
|
||||
// Navigate to the new workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
|
||||
} else {
|
||||
console.error('Cannot import workflow: state is not available')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle workflow click:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='block cursor-pointer'
|
||||
aria-label={`View ${workflow.name} workflow`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Card
|
||||
className='flex h-full flex-col overflow-hidden transition-all hover:shadow-md'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
{/* Workflow preview/thumbnail area */}
|
||||
<div className='relative h-40 overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900'>
|
||||
{isPreviewReady && workflow.workflowState ? (
|
||||
// Interactive Preview
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div className='h-full w-full scale-[0.9] transform-gpu'>
|
||||
<WorkflowPreview
|
||||
workflowState={{
|
||||
...workflow.workflowState,
|
||||
parallels: workflow.workflowState.parallels || {},
|
||||
loops: workflow.workflowState.loops || {},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : workflow.thumbnail ? (
|
||||
// Show static thumbnail image if available
|
||||
<div
|
||||
className='h-full w-full bg-center bg-cover'
|
||||
style={{
|
||||
backgroundImage: `url(${workflow.thumbnail})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center top',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// Fallback to text if no preview or thumbnail is available
|
||||
<div className='flex h-full w-full items-center justify-center'>
|
||||
<span className='font-medium text-lg text-muted-foreground'>{workflow.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-grow flex-col'>
|
||||
{/* Workflow title */}
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<h3 className='font-medium text-sm'>{workflow.name}</h3>
|
||||
</CardHeader>
|
||||
{/* Workflow description */}
|
||||
<CardContent className='flex flex-grow flex-col p-4 pt-0 pb-2'>
|
||||
<p className='line-clamp-2 text-muted-foreground text-xs'>{workflow.description}</p>
|
||||
</CardContent>
|
||||
{/* Footer with author and stats */}
|
||||
<CardFooter className='mt-auto flex items-center justify-between p-4 pt-2'>
|
||||
<div className='text-muted-foreground text-xs'>by {workflow.author}</div>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<Eye className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
<span className='font-medium text-muted-foreground text-xs'>{workflow.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Atom,
|
||||
BotMessageSquare,
|
||||
Brain,
|
||||
Code,
|
||||
Database,
|
||||
LineChart,
|
||||
MailIcon,
|
||||
Store,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface Category {
|
||||
value: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
color: string
|
||||
}
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{
|
||||
value: 'data',
|
||||
label: 'Data Analysis',
|
||||
icon: <Database className='mr-2 h-4 w-4' />,
|
||||
color: '#0ea5e9', // sky-500
|
||||
},
|
||||
{
|
||||
value: 'marketing',
|
||||
label: 'Marketing',
|
||||
icon: <MailIcon className='mr-2 h-4 w-4' />,
|
||||
color: '#f43f5e', // rose-500
|
||||
},
|
||||
{
|
||||
value: 'sales',
|
||||
label: 'Sales',
|
||||
icon: <Store className='mr-2 h-4 w-4' />,
|
||||
color: '#10b981', // emerald-500
|
||||
},
|
||||
{
|
||||
value: 'customer_service',
|
||||
label: 'Customer Service',
|
||||
icon: <BotMessageSquare className='mr-2 h-4 w-4' />,
|
||||
color: '#8b5cf6', // violet-500
|
||||
},
|
||||
{
|
||||
value: 'research',
|
||||
label: 'Research',
|
||||
icon: <Atom className='mr-2 h-4 w-4' />,
|
||||
color: '#f59e0b', // amber-500
|
||||
},
|
||||
{
|
||||
value: 'finance',
|
||||
label: 'Finance',
|
||||
icon: <LineChart className='mr-2 h-4 w-4' />,
|
||||
color: '#14b8a6', // teal-500
|
||||
},
|
||||
{
|
||||
value: 'programming',
|
||||
label: 'Programming',
|
||||
icon: <Code className='mr-2 h-4 w-4' />,
|
||||
color: '#6366f1', // indigo-500
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: 'Other',
|
||||
icon: <Brain className='mr-2 h-4 w-4' />,
|
||||
color: '#802FFF', // Brand purple
|
||||
},
|
||||
]
|
||||
|
||||
// Helper functions to get category information
|
||||
export const getCategoryByValue = (value: string): Category => {
|
||||
return CATEGORIES.find((cat) => cat.value === value) || CATEGORIES[CATEGORIES.length - 1]
|
||||
}
|
||||
|
||||
export const getCategoryLabel = (value: string): string => {
|
||||
// Special handling for "popular" and "recent" sections
|
||||
if (value === 'popular') return 'Popular'
|
||||
if (value === 'recent') return 'Recent'
|
||||
|
||||
// Default handling for regular categories
|
||||
return getCategoryByValue(value).label
|
||||
}
|
||||
|
||||
export const getCategoryIcon = (value: string): ReactNode => {
|
||||
return getCategoryByValue(value).icon
|
||||
}
|
||||
|
||||
export const getCategoryColor = (value: string): string => {
|
||||
return getCategoryByValue(value).color
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { ControlBar } from './components/control-bar/control-bar'
|
||||
import { ErrorMessage } from './components/error-message'
|
||||
import { Section } from './components/section'
|
||||
import { Toolbar } from './components/toolbar/toolbar'
|
||||
import { WorkflowCard } from './components/workflow-card'
|
||||
import { WorkflowCardSkeleton } from './components/workflow-card-skeleton'
|
||||
import { CATEGORIES, getCategoryLabel } from './constants/categories'
|
||||
|
||||
// Types
|
||||
export interface Workflow {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
author: string
|
||||
views: number
|
||||
tags: string[]
|
||||
thumbnail?: string
|
||||
workflowUrl: string
|
||||
workflowState?: {
|
||||
blocks: Record<string, any>
|
||||
edges: Array<{
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}>
|
||||
loops: Record<string, any>
|
||||
parallels?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
// Updated interface to match API response format
|
||||
export interface MarketplaceWorkflow {
|
||||
id: string
|
||||
workflowId: string
|
||||
name: string
|
||||
description: string
|
||||
authorName: string
|
||||
views: number
|
||||
category: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
workflowState?: {
|
||||
blocks: Record<string, any>
|
||||
edges: Array<{
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}>
|
||||
loops: Record<string, any>
|
||||
parallels?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarketplaceData {
|
||||
popular: MarketplaceWorkflow[]
|
||||
recent: MarketplaceWorkflow[]
|
||||
byCategory: Record<string, MarketplaceWorkflow[]>
|
||||
}
|
||||
|
||||
// The order to display sections in, matching toolbar order
|
||||
const SECTION_ORDER = ['popular', 'recent', ...CATEGORIES.map((cat) => cat.value)]
|
||||
|
||||
export default function Marketplace() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [marketplaceData, setMarketplaceData] = useState<MarketplaceData>({
|
||||
popular: [],
|
||||
recent: [],
|
||||
byCategory: {},
|
||||
})
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null)
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set(['popular', 'recent']))
|
||||
const [_visibleSections, setVisibleSections] = useState<Set<string>>(new Set(['popular']))
|
||||
|
||||
// Create refs for each section
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const initialFetchCompleted = useRef(false)
|
||||
|
||||
// Convert marketplace data to the format expected by components
|
||||
const workflowData = useMemo(() => {
|
||||
const result: Record<string, Workflow[]> = {
|
||||
popular: marketplaceData.popular.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
author: item.authorName,
|
||||
views: item.views,
|
||||
tags: [item.category],
|
||||
workflowState: item.workflowState,
|
||||
workflowUrl: `/w/${item.workflowId}`,
|
||||
})),
|
||||
recent: marketplaceData.recent.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
author: item.authorName,
|
||||
views: item.views,
|
||||
tags: [item.category],
|
||||
workflowState: item.workflowState,
|
||||
workflowUrl: `/w/${item.workflowId}`,
|
||||
})),
|
||||
}
|
||||
|
||||
// Add entries for each category
|
||||
Object.entries(marketplaceData.byCategory).forEach(([category, items]) => {
|
||||
if (items && items.length > 0) {
|
||||
result[category] = items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
author: item.authorName,
|
||||
views: item.views,
|
||||
tags: [item.category],
|
||||
workflowState: item.workflowState,
|
||||
workflowUrl: `/w/${item.workflowId}`,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}, [marketplaceData])
|
||||
|
||||
// Fetch workflows on component mount - improved to include state initially
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Fetch ALL data including categories in the initial load
|
||||
const response = await fetch(
|
||||
'/api/marketplace/workflows?includeState=true§ion=popular,recent,byCategory'
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch marketplace data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Add all categories to loaded sections to avoid redundant load
|
||||
setLoadedSections((prev) => {
|
||||
const allSections = new Set([...prev])
|
||||
Object.keys(data.byCategory || {}).forEach((category) => {
|
||||
allSections.add(category)
|
||||
})
|
||||
return allSections
|
||||
})
|
||||
|
||||
console.log(
|
||||
'Initial marketplace data loaded with categories:',
|
||||
data.popular?.length || 0,
|
||||
'popular,',
|
||||
data.recent?.length || 0,
|
||||
'recent,',
|
||||
'categories:',
|
||||
Object.keys(data.byCategory || {})
|
||||
)
|
||||
|
||||
setMarketplaceData(data)
|
||||
initialFetchCompleted.current = true
|
||||
|
||||
// Set initial active section to popular
|
||||
setActiveSection('popular')
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflows:', error)
|
||||
setError('Failed to load workflows. Please try again later.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInitialData()
|
||||
}, [])
|
||||
|
||||
// Lazy load category data when sections become visible
|
||||
const loadCategoryData = async (categoryName: string) => {
|
||||
if (loadedSections.has(categoryName)) {
|
||||
return // Already loaded, no need to fetch again
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadedSections((prev) => new Set([...prev, categoryName]))
|
||||
|
||||
console.log(`Loading category: ${categoryName}`) // Debug
|
||||
|
||||
const response = await fetch(
|
||||
`/api/marketplace/workflows?includeState=true&category=${categoryName}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${categoryName} category data`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
'Category data received:',
|
||||
data.byCategory ? Object.keys(data.byCategory) : 'No byCategory',
|
||||
data.byCategory?.[categoryName]?.length || 0
|
||||
)
|
||||
|
||||
// Check if we received any data in the category
|
||||
if (
|
||||
!data.byCategory ||
|
||||
!data.byCategory[categoryName] ||
|
||||
data.byCategory[categoryName].length === 0
|
||||
) {
|
||||
console.warn(`No items found for category: ${categoryName}`)
|
||||
}
|
||||
|
||||
setMarketplaceData((prev) => ({
|
||||
...prev,
|
||||
byCategory: {
|
||||
...prev.byCategory,
|
||||
[categoryName]: data.byCategory?.[categoryName] || [],
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${categoryName} category:`, error)
|
||||
// We don't set a global error, just log it
|
||||
}
|
||||
}
|
||||
|
||||
// Function to mark a workflow as needing state and fetch it if not available
|
||||
const ensureWorkflowState = async (workflowId: string) => {
|
||||
try {
|
||||
// Find which section contains this workflow
|
||||
let foundWorkflow: MarketplaceWorkflow | undefined
|
||||
|
||||
// Check in popular section
|
||||
foundWorkflow = marketplaceData.popular.find((w) => w.id === workflowId)
|
||||
|
||||
// Check in recent section if not found
|
||||
if (!foundWorkflow) {
|
||||
foundWorkflow = marketplaceData.recent.find((w) => w.id === workflowId)
|
||||
}
|
||||
|
||||
// Check in category sections if not found
|
||||
if (!foundWorkflow) {
|
||||
for (const category of Object.keys(marketplaceData.byCategory)) {
|
||||
foundWorkflow = marketplaceData.byCategory[category].find((w) => w.id === workflowId)
|
||||
if (foundWorkflow) break
|
||||
}
|
||||
}
|
||||
|
||||
// If we have the workflow but it doesn't have state, fetch it
|
||||
if (foundWorkflow && !foundWorkflow.workflowState) {
|
||||
const response = await fetch(
|
||||
`/api/marketplace/workflows?marketplaceId=${workflowId}&includeState=true`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Update the workflow data with the state
|
||||
setMarketplaceData((prevData) => {
|
||||
const updatedData = { ...prevData }
|
||||
|
||||
// Helper function to update workflow in a section
|
||||
const updateWorkflowInSection = (workflows: MarketplaceWorkflow[]) => {
|
||||
return workflows.map((w) =>
|
||||
w.id === workflowId
|
||||
? {
|
||||
...w,
|
||||
workflowState: data.data.workflowState,
|
||||
}
|
||||
: w
|
||||
)
|
||||
}
|
||||
|
||||
// Update in popular
|
||||
updatedData.popular = updateWorkflowInSection(updatedData.popular)
|
||||
|
||||
// Update in recent
|
||||
updatedData.recent = updateWorkflowInSection(updatedData.recent)
|
||||
|
||||
// Update in categories
|
||||
Object.keys(updatedData.byCategory).forEach((category) => {
|
||||
updatedData.byCategory[category] = updateWorkflowInSection(
|
||||
updatedData.byCategory[category]
|
||||
)
|
||||
})
|
||||
|
||||
return updatedData
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error ensuring workflow state for ${workflowId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter workflows based on search query
|
||||
const filteredWorkflows = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return workflowData
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
const filtered: Record<string, Workflow[]> = {}
|
||||
|
||||
Object.entries(workflowData).forEach(([category, workflows]) => {
|
||||
const matchingWorkflows = workflows.filter(
|
||||
(workflow) =>
|
||||
workflow.name.toLowerCase().includes(query) ||
|
||||
workflow.description.toLowerCase().includes(query) ||
|
||||
workflow.author.toLowerCase().includes(query) ||
|
||||
workflow.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
)
|
||||
|
||||
if (matchingWorkflows.length > 0) {
|
||||
filtered[category] = matchingWorkflows
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [searchQuery, workflowData])
|
||||
|
||||
// Sort sections according to the toolbar order
|
||||
const sortedFilteredWorkflows = useMemo(() => {
|
||||
// Get entries from filteredWorkflows
|
||||
const entries = Object.entries(filteredWorkflows)
|
||||
|
||||
// Sort based on the SECTION_ORDER
|
||||
entries.sort((a, b) => {
|
||||
const indexA = SECTION_ORDER.indexOf(a[0])
|
||||
const indexB = SECTION_ORDER.indexOf(b[0])
|
||||
|
||||
// If both categories are in our predefined order, use that order
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB
|
||||
}
|
||||
|
||||
// If only one category is in our order, prioritize it
|
||||
if (indexA !== -1) return -1
|
||||
if (indexB !== -1) return 1
|
||||
|
||||
// Otherwise, alphabetical order
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
|
||||
return entries
|
||||
}, [filteredWorkflows])
|
||||
|
||||
// Function to scroll to a specific section
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
if (sectionRefs.current[sectionId]) {
|
||||
// Load the section data if not already loaded
|
||||
if (!loadedSections.has(sectionId) && sectionId !== 'popular' && sectionId !== 'recent') {
|
||||
loadCategoryData(sectionId)
|
||||
}
|
||||
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Setup intersection observer to track active section and load sections as they become visible
|
||||
useEffect(() => {
|
||||
if (!initialFetchCompleted.current) return
|
||||
|
||||
// Function to get current section IDs in their display order
|
||||
const getCurrentSectionIds = () => {
|
||||
return Object.keys(filteredWorkflows).filter(
|
||||
(key) => filteredWorkflows[key] && filteredWorkflows[key].length > 0
|
||||
)
|
||||
}
|
||||
|
||||
// Create intersection observer to detect when sections enter viewport
|
||||
const observeSections = () => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const sectionId = entry.target.id
|
||||
|
||||
// Update visibility tracking
|
||||
if (entry.isIntersecting) {
|
||||
setVisibleSections((prev) => {
|
||||
const updated = new Set(prev)
|
||||
updated.add(sectionId)
|
||||
return updated
|
||||
})
|
||||
|
||||
// Load category data if section is visible and not loaded yet
|
||||
if (
|
||||
!loadedSections.has(sectionId) &&
|
||||
sectionId !== 'popular' &&
|
||||
sectionId !== 'recent'
|
||||
) {
|
||||
loadCategoryData(sectionId)
|
||||
}
|
||||
} else {
|
||||
setVisibleSections((prev) => {
|
||||
const updated = new Set(prev)
|
||||
updated.delete(sectionId)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
root: contentRef.current,
|
||||
rootMargin: '200px 0px', // Load sections slightly before they become visible
|
||||
threshold: 0.1,
|
||||
}
|
||||
)
|
||||
|
||||
// Observe all sections
|
||||
Object.entries(sectionRefs.current).forEach(([id, ref]) => {
|
||||
if (ref) {
|
||||
observer.observe(ref)
|
||||
}
|
||||
})
|
||||
|
||||
return observer
|
||||
}
|
||||
|
||||
const observer = observeSections()
|
||||
|
||||
// Use a single source of truth for determining the active section
|
||||
const determineActiveSection = () => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentRef.current
|
||||
const viewportTop = scrollTop
|
||||
const viewportMiddle = viewportTop + clientHeight / 2
|
||||
const viewportBottom = scrollTop + clientHeight
|
||||
const isAtBottom = viewportBottom >= scrollHeight - 50
|
||||
const isAtTop = viewportTop <= 20
|
||||
|
||||
const currentSectionIds = getCurrentSectionIds()
|
||||
|
||||
// Handle edge cases first
|
||||
if (isAtTop && currentSectionIds.length > 0) {
|
||||
setActiveSection(currentSectionIds[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (isAtBottom && currentSectionIds.length > 0) {
|
||||
setActiveSection(currentSectionIds[currentSectionIds.length - 1])
|
||||
return
|
||||
}
|
||||
|
||||
// Find section whose position is closest to middle of viewport
|
||||
// This creates smoother transitions as we scroll
|
||||
let closestSection = null
|
||||
let closestDistance = Number.POSITIVE_INFINITY
|
||||
|
||||
Object.entries(sectionRefs.current).forEach(([id, ref]) => {
|
||||
if (!ref || !currentSectionIds.includes(id)) return
|
||||
|
||||
const rect = ref.getBoundingClientRect()
|
||||
const sectionTop =
|
||||
rect.top + scrollTop - (contentRef.current?.getBoundingClientRect().top || 0)
|
||||
const sectionMiddle = sectionTop + rect.height / 2
|
||||
const distance = Math.abs(viewportMiddle - sectionMiddle)
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance
|
||||
closestSection = id
|
||||
}
|
||||
})
|
||||
|
||||
if (closestSection) {
|
||||
setActiveSection(closestSection)
|
||||
}
|
||||
}
|
||||
|
||||
// Use a passive scroll listener for smooth transitions
|
||||
const handleScroll = () => {
|
||||
// Using requestAnimationFrame ensures we only calculate
|
||||
// section positions during a paint frame, reducing jank
|
||||
window.requestAnimationFrame(determineActiveSection)
|
||||
}
|
||||
|
||||
const contentElement = contentRef.current
|
||||
contentElement?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
contentElement?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [initialFetchCompleted.current, loading, filteredWorkflows, loadedSections])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
{/* Control Bar */}
|
||||
<ControlBar setSearchQuery={setSearchQuery} />
|
||||
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
{/* Toolbar */}
|
||||
<Toolbar scrollToSection={scrollToSection} activeSection={activeSection} />
|
||||
|
||||
{/* Main content */}
|
||||
<div ref={contentRef} className='flex-1 overflow-y-auto px-6 py-6 pb-16'>
|
||||
{/* Error message */}
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Section
|
||||
id='loading'
|
||||
title='Popular'
|
||||
ref={(el) => {
|
||||
sectionRefs.current.loading = el
|
||||
}}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<WorkflowCardSkeleton key={`skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Render workflow sections */}
|
||||
{!loading && (
|
||||
<>
|
||||
{sortedFilteredWorkflows.map(
|
||||
([category, workflows]) =>
|
||||
workflows.length > 0 && (
|
||||
<Section
|
||||
key={category}
|
||||
id={category}
|
||||
title={getCategoryLabel(category)}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
sectionRefs.current[category] = el
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{workflows.map((workflow, index) => (
|
||||
<WorkflowCard
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
index={index}
|
||||
onHover={ensureWorkflowState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
)}
|
||||
|
||||
{sortedFilteredWorkflows.length === 0 && !loading && (
|
||||
<div className='flex h-64 flex-col items-center justify-center'>
|
||||
<AlertCircle className='mb-4 h-8 w-8 text-muted-foreground' />
|
||||
<p className='text-muted-foreground'>No workflows found matching your search.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Marketplace from './marketplace'
|
||||
|
||||
export default Marketplace
|
||||
126
apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx
Normal file
126
apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { templateStars, templates } from '@/db/schema'
|
||||
import type { Template } from '../templates'
|
||||
import TemplateDetails from './template'
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
|
||||
try {
|
||||
// Validate the template ID format (basic UUID validation)
|
||||
if (!id || typeof id !== 'string' || id.length !== 36) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return <div>Please log in to view this template</div>
|
||||
}
|
||||
|
||||
// Fetch template data first without star status to avoid query issues
|
||||
const templateData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (templateData.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const template = templateData[0]
|
||||
|
||||
// Validate that required fields are present
|
||||
if (!template.id || !template.name || !template.author) {
|
||||
console.error('Template missing required fields:', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
author: template.author,
|
||||
})
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if user has starred this template
|
||||
let isStarred = false
|
||||
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 {
|
||||
// Continue with isStarred = false
|
||||
}
|
||||
|
||||
// Ensure proper serialization of the template data with null checks
|
||||
const serializedTemplate: Template = {
|
||||
id: template.id,
|
||||
workflowId: template.workflowId,
|
||||
userId: template.userId,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
views: template.views,
|
||||
stars: template.stars,
|
||||
color: template.color || '#3972F6', // Default color if missing
|
||||
icon: template.icon || 'FileText', // Default icon if missing
|
||||
category: template.category as any,
|
||||
state: template.state as any,
|
||||
createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(),
|
||||
updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(),
|
||||
isStarred,
|
||||
}
|
||||
|
||||
console.log('Template from DB:', template)
|
||||
console.log('Serialized template:', serializedTemplate)
|
||||
console.log('Template state from DB:', template.state)
|
||||
|
||||
return (
|
||||
<TemplateDetails
|
||||
template={serializedTemplate}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={session.user.id}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error loading template:', error)
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 font-bold text-2xl'>Error Loading Template</h1>
|
||||
<p className='text-muted-foreground'>There was an error loading this template.</p>
|
||||
<p className='mt-2 text-muted-foreground text-sm'>Template ID: {id}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
348
apps/sim/app/workspace/[workspaceId]/templates/[id]/template.tsx
Normal file
348
apps/sim/app/workspace/[workspaceId]/templates/[id]/template.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import { 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 } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { Template } from '../templates'
|
||||
import { categories } from '../templates'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
|
||||
interface TemplateDetailsProps {
|
||||
template: Template
|
||||
workspaceId: string
|
||||
currentUserId: string
|
||||
}
|
||||
|
||||
// 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-card logic
|
||||
const getIconComponent = (icon: string): React.ReactNode => {
|
||||
const IconComponent = iconMap[icon as keyof typeof iconMap]
|
||||
return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' />
|
||||
}
|
||||
|
||||
// Get category display name
|
||||
const getCategoryDisplayName = (categoryValue: string): string => {
|
||||
const category = categories.find((c) => c.value === categoryValue)
|
||||
return category?.label || categoryValue
|
||||
}
|
||||
|
||||
export default function TemplateDetails({
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
}: TemplateDetailsProps) {
|
||||
const router = useRouter()
|
||||
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
|
||||
const [starCount, setStarCount] = useState(template?.stars || 0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
|
||||
// Defensive check for template after hooks are initialized
|
||||
if (!template) {
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
|
||||
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render workflow preview exactly like deploy-modal.tsx
|
||||
const renderWorkflowPreview = () => {
|
||||
// Follow the same pattern as deployed-workflow-card.tsx
|
||||
if (!template?.state) {
|
||||
console.log('Template has no state:', template)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-muted-foreground'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ No Workflow Data</div>
|
||||
<div className='text-sm'>This template doesn't contain workflow state data.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Template state:', template.state)
|
||||
console.log('Template state type:', typeof template.state)
|
||||
console.log('Template state blocks:', template.state.blocks)
|
||||
console.log('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) {
|
||||
console.error('Error rendering workflow preview:', error)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-muted-foreground'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ Preview Error</div>
|
||||
<div className='text-sm'>Unable to render workflow preview</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring) 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
|
||||
|
||||
setIsUsing(true)
|
||||
try {
|
||||
// TODO: Implement proper template usage logic
|
||||
// This should create a new workflow from the template state
|
||||
// For now, we'll create a basic workflow and navigate to it
|
||||
logger.info('Using template:', template.id)
|
||||
|
||||
// Create a new workflow
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `${template.name} (Copy)`,
|
||||
description: `Created from template: ${template.name}`,
|
||||
color: template.color,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create workflow from template')
|
||||
}
|
||||
|
||||
const newWorkflow = await response.json()
|
||||
|
||||
// Navigate to the new workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${newWorkflow.id}`)
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
// Show error to user (could implement toast notification)
|
||||
} finally {
|
||||
setIsUsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
{/* Header */}
|
||||
<div className='border-b bg-background p-6'>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
<span className='text-sm'>Go back</span>
|
||||
</button>
|
||||
|
||||
{/* Template header */}
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex items-start gap-4'>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className='flex h-12 w-12 items-center justify-center rounded-lg'
|
||||
style={{ backgroundColor: template.color }}
|
||||
>
|
||||
{getIconComponent(template.icon)}
|
||||
</div>
|
||||
|
||||
{/* Title and description */}
|
||||
<div>
|
||||
<h1 className='font-bold text-3xl text-foreground'>{template.name}</h1>
|
||||
<p className='mt-2 max-w-3xl text-lg text-muted-foreground'>
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* Star button */}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleStarToggle}
|
||||
disabled={isStarring}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
|
||||
)}
|
||||
>
|
||||
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
|
||||
{starCount}
|
||||
</Button>
|
||||
|
||||
{/* Use template button */}
|
||||
<Button
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
className='bg-purple-600 text-white hover:bg-purple-700'
|
||||
>
|
||||
Use this template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
|
||||
{/* Category */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<span>{getCategoryDisplayName(template.category)}</span>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<Eye className='h-3 w-3' />
|
||||
<span>{template.views}</span>
|
||||
</div>
|
||||
|
||||
{/* Stars */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{starCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<User className='h-3 w-3' />
|
||||
<span>by {template.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow preview */}
|
||||
<div className='flex-1 p-6'>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<h2 className='mb-4 font-semibold text-xl'>Workflow Preview</h2>
|
||||
<div className='h-[600px] w-full'>{renderWorkflowPreview()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
// 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
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Add state prop to extract block types
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
// Add handlers for star and use actions
|
||||
onStar?: (templateId: string, isCurrentlyStarred: boolean) => Promise<void>
|
||||
onUse?: (templateId: string) => Promise<void>
|
||||
isStarred?: boolean
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-[14px] border bg-card shadow-xs', 'flex h-38', 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-3'>
|
||||
<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 bg-gray-200' />
|
||||
{/* Title skeleton */}
|
||||
<div className='h-4 w-24 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className='space-y-2'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3/4 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-1/2 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section skeleton */}
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 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' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Blocks skeleton */}
|
||||
<div className='flex w-16 flex-col gap-1 rounded-r-[14px] border-border border-l bg-secondary p-2'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className='flex items-center gap-1.5'>
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
))}
|
||||
</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 icon component from string or return the component directly
|
||||
const getIconComponent = (icon: React.ReactNode | string | undefined): React.ReactNode => {
|
||||
if (typeof icon === 'string') {
|
||||
const IconComponent = iconMap[icon as keyof typeof iconMap]
|
||||
return IconComponent ? <IconComponent /> : <FileText />
|
||||
}
|
||||
if (icon) {
|
||||
return icon
|
||||
}
|
||||
// Default fallback icon
|
||||
return <FileText />
|
||||
}
|
||||
|
||||
// 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,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
onStar,
|
||||
onUse,
|
||||
isStarred = false,
|
||||
}: TemplateCardProps) {
|
||||
// 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()
|
||||
|
||||
// Get the icon component
|
||||
const iconComponent = getIconComponent(icon)
|
||||
|
||||
// Handle star toggle
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onStar) {
|
||||
await onStar(id, isStarred)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use template
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onUse) {
|
||||
await onUse(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-[14px] border bg-card shadow-xs transition-all 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-3'>
|
||||
<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 container */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
|
||||
// Use CSS class if iconColor doesn't start with #
|
||||
iconColor?.startsWith('#') ? '' : iconColor || 'bg-blue-500'
|
||||
)}
|
||||
style={{
|
||||
// Use inline style for hex colors
|
||||
backgroundColor: iconColor?.startsWith('#') ? iconColor : undefined,
|
||||
}}
|
||||
>
|
||||
<div className='h-3 w-3 text-white [&>svg]:h-3 [&>svg]:w-3'>{iconComponent}</div>
|
||||
</div>
|
||||
{/* Template name */}
|
||||
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Star and Use button */}
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors',
|
||||
isStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUseClick}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 font-medium font-sans text-white text-xs transition-all duration-200',
|
||||
'bg-[#701FFC] hover:bg-[#6518E6]',
|
||||
'shadow-[0_0_0_0_#701FFC] 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 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>{stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[14px] 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'
|
||||
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 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'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={GeistSans.className}>{children}</div>
|
||||
}
|
||||
47
apps/sim/app/workspace/[workspaceId]/templates/page.tsx
Normal file
47
apps/sim/app/workspace/[workspaceId]/templates/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { templateStars, templates } from '@/db/schema'
|
||||
import type { Template } from './templates'
|
||||
import Templates from './templates'
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return <div>Please log in to view templates</div>
|
||||
}
|
||||
|
||||
// Fetch templates server-side with all necessary data
|
||||
const templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={templatesData as unknown as Template[]}
|
||||
currentUserId={session.user.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
432
apps/sim/app/workspace/[workspaceId]/templates/templates.tsx
Normal file
432
apps/sim/app/workspace/[workspaceId]/templates/templates.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronRight, Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { NavigationTabs } from './components/navigation-tabs'
|
||||
import { TemplateCard, TemplateCardSkeleton } from './components/template-card'
|
||||
|
||||
const logger = createLogger('TemplatesPage')
|
||||
|
||||
// Shared categories definition
|
||||
export const categories = [
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'finance', label: 'Finance' },
|
||||
{ value: 'support', label: 'Support' },
|
||||
{ value: 'artificial-intelligence', label: 'Artificial Intelligence' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
export type CategoryValue = (typeof categories)[number]['value']
|
||||
|
||||
// Template data structure
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string
|
||||
userId: string
|
||||
name: string
|
||||
description: string | null
|
||||
author: string
|
||||
views: number
|
||||
stars: number
|
||||
color: string
|
||||
icon: string
|
||||
category: CategoryValue
|
||||
state: WorkflowState
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
isStarred: boolean
|
||||
}
|
||||
|
||||
interface TemplatesProps {
|
||||
initialTemplates: Template[]
|
||||
currentUserId: string
|
||||
}
|
||||
|
||||
export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('your')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Refs for scrolling to sections
|
||||
const sectionRefs = {
|
||||
your: useRef<HTMLDivElement>(null),
|
||||
recent: useRef<HTMLDivElement>(null),
|
||||
marketing: useRef<HTMLDivElement>(null),
|
||||
sales: useRef<HTMLDivElement>(null),
|
||||
finance: useRef<HTMLDivElement>(null),
|
||||
support: useRef<HTMLDivElement>(null),
|
||||
'artificial-intelligence': useRef<HTMLDivElement>(null),
|
||||
other: useRef<HTMLDivElement>(null),
|
||||
}
|
||||
|
||||
// Get your templates count (created by user OR starred by user)
|
||||
const yourTemplatesCount = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
).length
|
||||
|
||||
// Handle case where active tab is "your" but user has no templates
|
||||
useEffect(() => {
|
||||
if (!loading && activeTab === 'your' && yourTemplatesCount === 0) {
|
||||
setActiveTab('recent') // Switch to recent tab
|
||||
}
|
||||
}, [loading, activeTab, yourTemplatesCount])
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs]
|
||||
if (sectionRef.current) {
|
||||
sectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateClick = (templateId: string) => {
|
||||
// Navigate to template detail page
|
||||
router.push(`/workspace/${params.workspaceId}/templates/${templateId}`)
|
||||
}
|
||||
|
||||
// Handle using a template
|
||||
const handleUseTemplate = async (templateId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}/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)
|
||||
|
||||
// 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 handleCreateNew = () => {
|
||||
// TODO: Open create template modal or navigate to create page
|
||||
console.log('Create new template')
|
||||
}
|
||||
|
||||
// Handle starring/unstarring templates (client-side for interactivity)
|
||||
const handleStarToggle = async (templateId: string, isCurrentlyStarred: boolean) => {
|
||||
try {
|
||||
const method = isCurrentlyStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${templateId}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state optimistically
|
||||
setTemplates((prev) =>
|
||||
prev.map((template) =>
|
||||
template.id === templateId
|
||||
? {
|
||||
...template,
|
||||
isStarred: !isCurrentlyStarred,
|
||||
stars: isCurrentlyStarred ? template.stars - 1 : template.stars + 1,
|
||||
}
|
||||
: template
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
|
||||
let filteredByCategory = templates
|
||||
|
||||
if (category === 'your') {
|
||||
// For "your" templates, show templates created by you OR starred by you
|
||||
filteredByCategory = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
)
|
||||
} else if (category === 'recent') {
|
||||
// For "recent" templates, show the 8 most recent templates
|
||||
filteredByCategory = templates
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 8)
|
||||
} else {
|
||||
filteredByCategory = templates.filter((template) => template.category === category)
|
||||
}
|
||||
|
||||
if (!searchQuery) return filteredByCategory
|
||||
|
||||
return filteredByCategory.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to render template cards with proper type handling
|
||||
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 as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
onStar={handleStarToggle}
|
||||
onUse={handleUseTemplate}
|
||||
isStarred={template.isStarred}
|
||||
/>
|
||||
)
|
||||
|
||||
// Group templates by category for display
|
||||
const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => {
|
||||
return filteredTemplates(category)
|
||||
}
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
}
|
||||
|
||||
// Calculate navigation tabs with real counts or skeleton counts
|
||||
const navigationTabs = [
|
||||
// Only include "Your templates" tab if user has created or starred templates
|
||||
...(yourTemplatesCount > 0 || loading
|
||||
? [
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your templates',
|
||||
count: loading ? 8 : getTemplatesByCategory('your').length,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'recent',
|
||||
label: 'Recent',
|
||||
count: loading ? 8 : getTemplatesByCategory('recent').length,
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
label: 'Marketing',
|
||||
count: loading ? 8 : getTemplatesByCategory('marketing').length,
|
||||
},
|
||||
{ id: 'sales', label: 'Sales', count: loading ? 8 : getTemplatesByCategory('sales').length },
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finance',
|
||||
count: loading ? 8 : getTemplatesByCategory('finance').length,
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
label: 'Support',
|
||||
count: loading ? 8 : getTemplatesByCategory('support').length,
|
||||
},
|
||||
{
|
||||
id: 'artificial-intelligence',
|
||||
label: 'Artificial Intelligence',
|
||||
count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length,
|
||||
},
|
||||
{ id: 'other', label: 'Other', count: loading ? 8 : getTemplatesByCategory('other').length },
|
||||
]
|
||||
|
||||
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 p-6'>
|
||||
{/* Header */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
|
||||
Templates
|
||||
</h1>
|
||||
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
|
||||
Grab a template and start building, or make
|
||||
<br />
|
||||
one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Create New */}
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search templates...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
{/* <Button
|
||||
onClick={handleCreateNew}
|
||||
className='flex h-9 items-center gap-2 rounded-lg bg-[#701FFC] px-4 py-2 font-normal font-sans text-sm text-white hover:bg-[#601EE0]'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Create New
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className='mb-6'>
|
||||
<NavigationTabs
|
||||
tabs={navigationTabs}
|
||||
activeTab={activeTab}
|
||||
onTabClick={handleTabClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Your Templates Section */}
|
||||
{yourTemplatesCount > 0 || loading ? (
|
||||
<div ref={sectionRefs.your} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Your templates</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('your').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Recent Templates Section */}
|
||||
<div ref={sectionRefs.recent} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Recent</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Section */}
|
||||
<div ref={sectionRefs.marketing} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Marketing</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('marketing').map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales Section */}
|
||||
<div ref={sectionRefs.sales} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Sales</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finance Section */}
|
||||
<div ref={sectionRefs.finance} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Finance</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div ref={sectionRefs.support} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Support</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('support').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artificial Intelligence Section */}
|
||||
<div ref={sectionRefs['artificial-intelligence']} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>
|
||||
Artificial Intelligence
|
||||
</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('artificial-intelligence').map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Section */}
|
||||
<div ref={sectionRefs.other} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Other</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('other').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBaseDomain } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { OutputConfig } from '@/stores/panel/chat/types'
|
||||
|
||||
const logger = createLogger('ChatDeploy')
|
||||
@@ -88,9 +87,6 @@ export function ChatDeploy({
|
||||
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
|
||||
onDeploymentComplete,
|
||||
}: ChatDeployProps) {
|
||||
// Store hooks
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
// Form state
|
||||
const [subdomain, setSubdomain] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -748,7 +744,7 @@ export function ChatDeploy({
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error)
|
||||
setErrorMessage(error.message || 'An unexpected error occurred')
|
||||
addNotification('error', `Failed to deploy chat: ${error.message}`, workflowId)
|
||||
logger.error(`Failed to deploy chat: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setChatSubmitting(false)
|
||||
setShowEditConfirmation(false)
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ApiEndpoint } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
|
||||
import { ApiKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key'
|
||||
import { DeployStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status'
|
||||
import { ExampleCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { DeployedWorkflowModal } from '../../../deployment-controls/components/deployed-workflow-modal'
|
||||
|
||||
@@ -50,14 +49,11 @@ export function DeploymentInfo({
|
||||
isUndeploying,
|
||||
workflowId,
|
||||
deployedState,
|
||||
isLoadingDeployedState,
|
||||
}: DeploymentInfoProps) {
|
||||
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
const handleViewDeployed = async () => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'Cannot view deployment: Workflow ID is missing', null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,9 +62,6 @@ export function DeploymentInfo({
|
||||
setIsViewingDeployed(true)
|
||||
return
|
||||
}
|
||||
if (!isLoadingDeployedState) {
|
||||
addNotification('error', 'Cannot view deployment: No deployed state available', workflowId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !deploymentInfo) {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { cn } from '@/lib/utils'
|
||||
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
|
||||
import { DeployForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form'
|
||||
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -77,9 +76,6 @@ export function DeployModal({
|
||||
isLoadingDeployedState,
|
||||
refetchDeployedState,
|
||||
}: DeployModalProps) {
|
||||
// Store hooks
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
// Use registry store for deployment-related functions
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(workflowId)
|
||||
@@ -161,7 +157,6 @@ export function DeployModal({
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching API keys:', { error })
|
||||
addNotification('error', 'Failed to fetch API keys', workflowId)
|
||||
setKeysLoaded(true)
|
||||
}
|
||||
}
|
||||
@@ -243,22 +238,16 @@ export function DeployModal({
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching deployment info:', { error })
|
||||
addNotification('error', 'Failed to fetch deployment information', workflowId)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDeploymentInfo()
|
||||
}, [open, workflowId, isDeployed, addNotification, needsRedeployment])
|
||||
}, [open, workflowId, isDeployed, needsRedeployment])
|
||||
|
||||
// Handle form submission for deployment
|
||||
const onDeploy = async (data: DeployFormValues) => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'No active workflow to deploy', null)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset any previous errors
|
||||
setApiDeployError(null)
|
||||
|
||||
@@ -319,7 +308,6 @@ export function DeployModal({
|
||||
// No notification on successful deploy
|
||||
} catch (error: any) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -327,11 +315,6 @@ export function DeployModal({
|
||||
|
||||
// Handle workflow undeployment
|
||||
const handleUndeploy = async () => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'No active workflow to undeploy', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUndeploying(true)
|
||||
|
||||
@@ -351,14 +334,10 @@ export function DeployModal({
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
|
||||
// Add a success notification
|
||||
addNotification('info', 'Workflow successfully undeployed', workflowId)
|
||||
|
||||
// Close the modal
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
logger.error('Error undeploying workflow:', { error })
|
||||
addNotification('error', `Failed to undeploy workflow: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setIsUndeploying(false)
|
||||
}
|
||||
@@ -366,11 +345,6 @@ export function DeployModal({
|
||||
|
||||
// Handle redeployment of workflow
|
||||
const handleRedeploy = async () => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'No active workflow to redeploy', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
@@ -407,11 +381,8 @@ export function DeployModal({
|
||||
|
||||
// Fetch the updated deployed state after redeployment
|
||||
await refetchDeployedState()
|
||||
|
||||
addNotification('info', 'Workflow successfully redeployed', workflowId)
|
||||
} catch (error: any) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
addNotification('error', `Failed to redeploy workflow: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -427,11 +398,6 @@ export function DeployModal({
|
||||
|
||||
// Add a new handler for chat undeploy
|
||||
const handleChatUndeploy = async () => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'No active workflow to undeploy chat', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUndeploying(true)
|
||||
|
||||
@@ -462,15 +428,10 @@ export function DeployModal({
|
||||
// Reset chat deployment info
|
||||
setDeployedChatUrl(null)
|
||||
setChatExists(false)
|
||||
|
||||
// Add a success notification
|
||||
addNotification('info', 'Chat successfully undeployed', workflowId)
|
||||
|
||||
// Close the modal
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
logger.error('Error undeploying chat:', { error })
|
||||
addNotification('error', `Failed to undeploy chat: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setIsUndeploying(false)
|
||||
setShowDeleteConfirmation(false)
|
||||
@@ -479,11 +440,6 @@ export function DeployModal({
|
||||
|
||||
// Find or create appropriate method to handle chat deployment
|
||||
const handleChatSubmit = async () => {
|
||||
if (!workflowId) {
|
||||
addNotification('error', 'No active workflow to deploy', null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if workflow is deployed
|
||||
if (!isDeployed) {
|
||||
// Deploy workflow first
|
||||
@@ -518,7 +474,6 @@ export function DeployModal({
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error('Error auto-deploying workflow for chat:', { error })
|
||||
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,14 +86,16 @@ export function DeploymentControls({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='relative'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={handleDeployClick}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'hover:text-[#802FFF]',
|
||||
'h-12 w-12 rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] shadow-xs',
|
||||
'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
|
||||
'transition-all duration-200',
|
||||
isDeployed && 'text-[#802FFF]',
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
isDisabled &&
|
||||
'cursor-not-allowed opacity-50 hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:text-[hsl(var(--card-text))] hover:shadow-xs'
|
||||
)}
|
||||
>
|
||||
{isDeploying ? (
|
||||
@@ -105,10 +107,10 @@ export function DeploymentControls({
|
||||
</Button>
|
||||
|
||||
{isDeployed && workflowNeedsRedeployment && (
|
||||
<div className='absolute top-0.5 right-0.5 flex items-center justify-center'>
|
||||
<div className='pointer-events-none absolute right-2 bottom-2 flex items-center justify-center'>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 h-2 w-2 animate-ping rounded-full bg-amber-500/50' />
|
||||
<div className='zoom-in fade-in relative h-2 w-2 animate-in rounded-full bg-amber-500 ring-1 ring-background duration-300' />
|
||||
<div className='absolute inset-0 h-[6px] w-[6px] animate-ping rounded-full bg-amber-500/50' />
|
||||
<div className='zoom-in fade-in relative h-[6px] w-[6px] animate-in rounded-full bg-amber-500/80 duration-300' />
|
||||
</div>
|
||||
<span className='sr-only'>Needs Redeployment</span>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, FileText } from 'lucide-react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -63,44 +57,34 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || isExporting || !currentWorkflow
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (disabled) return 'Export not available'
|
||||
if (!currentWorkflow) return 'No workflow to export'
|
||||
if (isExporting) return 'Exporting...'
|
||||
return 'Export as YAML'
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
|
||||
<Download className='h-5 w-5' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
disabled={disabled || isExporting || !currentWorkflow}
|
||||
className='hover:text-foreground'
|
||||
variant='outline'
|
||||
onClick={handleExportYaml}
|
||||
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
|
||||
>
|
||||
<Download className='h-5 w-5' />
|
||||
<span className='sr-only'>Export Workflow</span>
|
||||
<span className='sr-only'>Export as YAML</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{disabled
|
||||
? 'Export not available'
|
||||
: !currentWorkflow
|
||||
? 'No workflow to export'
|
||||
: 'Export Workflow'}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{getTooltipText()}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
<DropdownMenuItem
|
||||
onClick={handleExportYaml}
|
||||
disabled={isExporting || !currentWorkflow}
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<FileText className='h-4 w-4' />
|
||||
<div className='flex flex-col'>
|
||||
<span>Export as YAML</span>
|
||||
<span className='text-muted-foreground text-xs'>workflow language</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HistoryDropdownItemProps {
|
||||
action: string
|
||||
timestamp: number
|
||||
onClick?: () => void
|
||||
isCurrent?: boolean
|
||||
isFuture?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function HistoryDropdownItem({
|
||||
action,
|
||||
timestamp,
|
||||
onClick,
|
||||
isCurrent = false,
|
||||
isFuture = false,
|
||||
id,
|
||||
}: HistoryDropdownItemProps) {
|
||||
const timeAgo = formatDistanceToNow(timestamp, { addSuffix: true })
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-2 p-3',
|
||||
isFuture && 'text-muted-foreground/50'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Clock
|
||||
className={cn('h-4 w-4', isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground')}
|
||||
/>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrent ? (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{timeAgo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={cn('text-sm', isFuture ? 'text-muted-foreground/50' : 'text-foreground')}>
|
||||
{action}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Eye, HelpCircle, Info, Trash, X } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
import { Notice } from '@/components/ui/notice'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CATEGORIES,
|
||||
getCategoryColor,
|
||||
getCategoryIcon,
|
||||
getCategoryLabel,
|
||||
} from '@/app/workspace/[workspaceId]/marketplace/constants/categories'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { getWorkflowWithValues } from '@/stores/workflows'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('MarketplaceModal')
|
||||
|
||||
/**
|
||||
* Sanitizes sensitive data from workflow state before publishing
|
||||
* Removes API keys, tokens, and environment variable references
|
||||
*/
|
||||
const sanitizeWorkflowData = (workflowData: any) => {
|
||||
if (!workflowData) return workflowData
|
||||
|
||||
const sanitizedData = JSON.parse(JSON.stringify(workflowData))
|
||||
let sanitizedCount = 0
|
||||
|
||||
// Handle workflow state format
|
||||
if (sanitizedData.state?.blocks) {
|
||||
Object.values(sanitizedData.state.blocks).forEach((block: any) => {
|
||||
if (block.subBlocks) {
|
||||
// Check for sensitive fields in subBlocks
|
||||
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
|
||||
// Check for API key related fields in any block type
|
||||
const isSensitiveField =
|
||||
key.toLowerCase() === 'apikey' || key.toLowerCase().includes('api_key')
|
||||
|
||||
if (isSensitiveField && subBlock.value) {
|
||||
subBlock.value = ''
|
||||
sanitizedCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Sanitized ${sanitizedCount} API keys from workflow data`)
|
||||
return sanitizedData
|
||||
}
|
||||
|
||||
interface MarketplaceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// Form schema for validation
|
||||
const marketplaceFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, 'Name must be at least 3 characters')
|
||||
.max(50, 'Name cannot exceed 50 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(10, 'Description must be at least 10 characters')
|
||||
.max(500, 'Description cannot exceed 500 characters'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
authorName: z
|
||||
.string()
|
||||
.min(2, 'Author name must be at least 2 characters')
|
||||
.max(50, 'Author name cannot exceed 50 characters'),
|
||||
})
|
||||
|
||||
type MarketplaceFormValues = z.infer<typeof marketplaceFormSchema>
|
||||
|
||||
// Tooltip texts
|
||||
const TOOLTIPS = {
|
||||
category: 'Categorizing your workflow helps users find it more easily.',
|
||||
authorName: 'The name you want to publish under (defaults to your account name if left empty).',
|
||||
}
|
||||
|
||||
interface MarketplaceInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
authorName: string
|
||||
views: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isUnpublishing, setIsUnpublishing] = useState(false)
|
||||
const [marketplaceInfo, setMarketplaceInfo] = useState<MarketplaceInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { activeWorkflowId, workflows, updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
// Get marketplace data from the registry
|
||||
const getMarketplaceData = () => {
|
||||
if (!activeWorkflowId || !workflows[activeWorkflowId]) return null
|
||||
return workflows[activeWorkflowId].marketplaceData
|
||||
}
|
||||
|
||||
// Check if workflow is published to marketplace
|
||||
const isPublished = () => {
|
||||
return !!getMarketplaceData()
|
||||
}
|
||||
|
||||
// Check if the current user is the owner of the published workflow
|
||||
const isOwner = () => {
|
||||
const marketplaceData = getMarketplaceData()
|
||||
return marketplaceData?.status === 'owner'
|
||||
}
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const form = useForm<MarketplaceFormValues>({
|
||||
resolver: zodResolver(marketplaceFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'marketing',
|
||||
authorName: '',
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch marketplace information when the modal opens and the workflow is published
|
||||
useEffect(() => {
|
||||
async function fetchMarketplaceInfo() {
|
||||
if (!open || !activeWorkflowId || !isPublished()) {
|
||||
setMarketplaceInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Get marketplace ID from the workflow's marketplaceData
|
||||
const marketplaceData = getMarketplaceData()
|
||||
if (!marketplaceData?.id) {
|
||||
throw new Error('No marketplace ID found in workflow data')
|
||||
}
|
||||
|
||||
// Use the marketplace ID to fetch details instead of workflow ID
|
||||
const response = await fetch(
|
||||
`/api/marketplace/workflows?marketplaceId=${marketplaceData.id}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch marketplace information')
|
||||
}
|
||||
|
||||
// The API returns the data directly without wrapping
|
||||
const marketplaceEntry = await response.json()
|
||||
setMarketplaceInfo(marketplaceEntry)
|
||||
} catch (error) {
|
||||
console.error('Error fetching marketplace info:', error)
|
||||
addNotification('error', 'Failed to fetch marketplace information', activeWorkflowId)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMarketplaceInfo()
|
||||
}, [open, activeWorkflowId, addNotification])
|
||||
|
||||
// Update form values when the active workflow changes or modal opens
|
||||
useEffect(() => {
|
||||
if (open && activeWorkflowId && workflows[activeWorkflowId] && !isPublished()) {
|
||||
const workflow = workflows[activeWorkflowId]
|
||||
form.setValue('name', workflow.name)
|
||||
form.setValue('description', workflow.description || '')
|
||||
}
|
||||
}, [open, activeWorkflowId, workflows, form])
|
||||
|
||||
// Listen for the custom event to open the marketplace modal
|
||||
useEffect(() => {
|
||||
const handleOpenMarketplace = () => {
|
||||
onOpenChange(true)
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('open-marketplace', handleOpenMarketplace as EventListener)
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener('open-marketplace', handleOpenMarketplace as EventListener)
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
const onSubmit = async (data: MarketplaceFormValues) => {
|
||||
if (!activeWorkflowId) {
|
||||
addNotification('error', 'No active workflow to publish', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Get the complete workflow state client-side
|
||||
const workflowData = getWorkflowWithValues(activeWorkflowId)
|
||||
if (!workflowData) {
|
||||
addNotification('error', 'Failed to retrieve workflow state', activeWorkflowId)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize the workflow data
|
||||
const sanitizedWorkflowData = sanitizeWorkflowData(workflowData)
|
||||
logger.info('Publishing sanitized workflow to marketplace', {
|
||||
workflowId: activeWorkflowId,
|
||||
workflowName: data.name,
|
||||
category: data.category,
|
||||
})
|
||||
|
||||
const response = await fetch('/api/marketplace/publish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowId: activeWorkflowId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
authorName: data.authorName,
|
||||
workflowState: sanitizedWorkflowData.state,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to publish workflow')
|
||||
}
|
||||
|
||||
// Get the marketplace ID from the response
|
||||
const responseData = await response.json()
|
||||
const marketplaceId = responseData.data.id
|
||||
|
||||
// Update the marketplace data in the workflow registry
|
||||
updateWorkflow(activeWorkflowId, {
|
||||
marketplaceData: { id: marketplaceId, status: 'owner' },
|
||||
})
|
||||
|
||||
// Add a marketplace notification with detailed information
|
||||
addNotification(
|
||||
'marketplace',
|
||||
`"${data.name}" successfully published to marketplace`,
|
||||
activeWorkflowId
|
||||
)
|
||||
|
||||
// Close the modal after successful submission
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
console.error('Error publishing workflow:', error)
|
||||
addNotification('error', `Failed to publish workflow: ${error.message}`, activeWorkflowId)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
if (!activeWorkflowId) {
|
||||
addNotification('error', 'No active workflow to unpublish', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUnpublishing(true)
|
||||
|
||||
// Get marketplace ID from the workflow's marketplaceData
|
||||
const marketplaceData = getMarketplaceData()
|
||||
if (!marketplaceData?.id) {
|
||||
throw new Error('No marketplace ID found in workflow data')
|
||||
}
|
||||
|
||||
logger.info('Attempting to unpublish marketplace entry', {
|
||||
marketplaceId: marketplaceData.id,
|
||||
workflowId: activeWorkflowId,
|
||||
status: marketplaceData.status,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/marketplace/${marketplaceData.id}/unpublish`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error response from unpublish endpoint', {
|
||||
status: response.status,
|
||||
data: errorData,
|
||||
})
|
||||
throw new Error(errorData.error || 'Failed to unpublish workflow')
|
||||
}
|
||||
|
||||
logger.info('Successfully unpublished workflow from marketplace', {
|
||||
marketplaceId: marketplaceData.id,
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
|
||||
// First close the modal to prevent any flashing
|
||||
onOpenChange(false)
|
||||
|
||||
// Then update the workflow state after modal is closed
|
||||
setTimeout(() => {
|
||||
// Remove the marketplace data from the workflow registry
|
||||
updateWorkflow(activeWorkflowId, {
|
||||
marketplaceData: null,
|
||||
})
|
||||
}, 100)
|
||||
} catch (error: any) {
|
||||
console.error('Error unpublishing workflow:', error)
|
||||
addNotification('error', `Failed to unpublish workflow: ${error.message}`, activeWorkflowId)
|
||||
} finally {
|
||||
setIsUnpublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const LabelWithTooltip = ({ name, tooltip }: { name: string; tooltip: string }) => (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<FormLabel>{name}</FormLabel>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className='h-4 w-4 cursor-help text-muted-foreground' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render marketplace information for published workflows
|
||||
const renderMarketplaceInfo = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<LoadingAgent size='md' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!marketplaceInfo) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12 text-muted-foreground'>
|
||||
<div className='flex flex-col items-center gap-2'>
|
||||
<Info className='h-5 w-5' />
|
||||
<p className='text-sm'>No marketplace information available</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-5 px-1'>
|
||||
{/* Header section with title and stats */}
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<h3 className='font-medium text-xl leading-tight'>{marketplaceInfo.name}</h3>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex items-center gap-1.5 rounded-md px-2 py-1'>
|
||||
<Eye className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
<span className='font-medium text-muted-foreground text-xs'>
|
||||
{marketplaceInfo.views}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{marketplaceInfo.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Category and Author Info */}
|
||||
<div className='flex items-center gap-6'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-muted-foreground text-xs'>Category</Label>
|
||||
<div
|
||||
className='flex items-center gap-1.5 rounded-md px-2.5 py-1'
|
||||
style={{
|
||||
backgroundColor: `${getCategoryColor(marketplaceInfo.category)}15`,
|
||||
color: getCategoryColor(marketplaceInfo.category),
|
||||
}}
|
||||
>
|
||||
{getCategoryIcon(marketplaceInfo.category)}
|
||||
<span className='font-medium text-sm'>
|
||||
{getCategoryLabel(marketplaceInfo.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-muted-foreground text-xs'>Author</Label>
|
||||
<div className='flex items-center font-medium text-sm'>
|
||||
{marketplaceInfo.authorName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - Only show unpublish if owner */}
|
||||
{isOwner() && (
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={handleUnpublish}
|
||||
disabled={isUnpublishing}
|
||||
className='gap-2'
|
||||
>
|
||||
{isUnpublishing ? (
|
||||
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
) : (
|
||||
<Trash className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{isUnpublishing ? 'Unpublishing...' : 'Unpublish'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render publish form for unpublished workflows
|
||||
const renderPublishForm = () => (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<Notice variant='warning' title='Security'>
|
||||
API keys and environment variables will be automatically removed before publishing.
|
||||
</Notice>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Workflow Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter workflow name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what your workflow does and how it can help others'
|
||||
className='min-h-24'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<LabelWithTooltip name='Category' tooltip={TOOLTIPS.category} />
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((category) => (
|
||||
<SelectItem
|
||||
key={category.value}
|
||||
value={category.value}
|
||||
className='flex items-center'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
{category.icon}
|
||||
{category.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<LabelWithTooltip name='Author Name' tooltip={TOOLTIPS.authorName} />
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-between gap-2'>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
// Base styles
|
||||
'gap-2 font-medium',
|
||||
// Brand color with hover states
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
// Hover effect with brand color
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
// Text color and transitions
|
||||
'text-white transition-all duration-200',
|
||||
// Running state animation
|
||||
isSubmitting &&
|
||||
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
|
||||
// Disabled state
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? 'Publishing...' : 'Publish Workflow'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex flex-col gap-0 p-0 sm:max-w-[600px]' hideCloseButton>
|
||||
<DialogHeader className='border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
{isPublished() ? 'Marketplace Information' : 'Publish to Marketplace'}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='overflow-y-auto px-6 pt-4 pb-6'>
|
||||
{isPublished() ? renderMarketplaceInfo() : renderPublishForm()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { AlertCircle, Rocket, Store, Terminal } from 'lucide-react'
|
||||
import { ErrorIcon } from '@/components/icons'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type {
|
||||
Notification,
|
||||
NotificationOptions,
|
||||
NotificationType,
|
||||
} from '@/stores/notifications/types'
|
||||
|
||||
interface NotificationDropdownItemProps {
|
||||
id: string
|
||||
type: NotificationType
|
||||
message: string
|
||||
timestamp: number
|
||||
options?: NotificationOptions
|
||||
setDropdownOpen?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const NotificationIcon = {
|
||||
error: ErrorIcon,
|
||||
console: Terminal,
|
||||
marketplace: Store,
|
||||
info: AlertCircle,
|
||||
api: Rocket,
|
||||
}
|
||||
|
||||
const NotificationColors = {
|
||||
error: 'text-destructive',
|
||||
console: 'text-foreground',
|
||||
marketplace: 'text-foreground',
|
||||
info: 'text-foreground',
|
||||
api: 'text-foreground',
|
||||
}
|
||||
|
||||
export function NotificationDropdownItem({
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
timestamp,
|
||||
options,
|
||||
setDropdownOpen,
|
||||
}: NotificationDropdownItemProps) {
|
||||
const { notifications, showNotification, hideNotification, removeNotification, addNotification } =
|
||||
useNotificationStore()
|
||||
const Icon = NotificationIcon[type]
|
||||
const [, forceUpdate] = useState({})
|
||||
|
||||
// Update the time display every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => forceUpdate({}), 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Find the full notification object from the store
|
||||
const getFullNotification = (): Notification | undefined => {
|
||||
return notifications.find((n) => n.id === id)
|
||||
}
|
||||
|
||||
// Handle click to show the notification
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const notification = getFullNotification()
|
||||
|
||||
if (notification) {
|
||||
// Simply show the notification regardless of its current state
|
||||
showNotification(id)
|
||||
} else {
|
||||
// Fallback for any case where the notification doesn't exist anymore
|
||||
addNotification(type, message, null, options)
|
||||
}
|
||||
|
||||
// Close the dropdown after clicking
|
||||
if (setDropdownOpen) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Format time and replace "less than a minute ago" with "<1 minute ago"
|
||||
const rawTimeAgo = formatDistanceToNow(timestamp, { addSuffix: true })
|
||||
const timeAgo = rawTimeAgo.replace('less than a minute ago', '<1 minute ago')
|
||||
|
||||
return (
|
||||
<DropdownMenuItem className='flex cursor-pointer items-start gap-2 p-3' onClick={handleClick}>
|
||||
<Icon className={cn('h-4 w-4', NotificationColors[type])} />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-xs'>
|
||||
{type === 'error'
|
||||
? 'Error'
|
||||
: type === 'marketplace'
|
||||
? 'Marketplace'
|
||||
: type === 'info'
|
||||
? 'Info'
|
||||
: 'Console'}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>{timeAgo}</span>
|
||||
</div>
|
||||
<p className='overflow-wrap-anywhere hyphens-auto whitespace-normal break-normal text-foreground text-sm'>
|
||||
{message.length > 100 ? `${message.slice(0, 60)}...` : message}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Loader2,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ColorPicker } from '@/components/ui/color-picker'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
|
||||
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
|
||||
const logger = createLogger('TemplateModal')
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
author: z
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
})
|
||||
|
||||
type TemplateFormData = z.infer<typeof templateSchema>
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
// Enhanced icon selection with category-relevant icons
|
||||
const icons = [
|
||||
// Content & Documentation
|
||||
{ value: 'FileText', label: 'File Text', component: FileText },
|
||||
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
|
||||
{ value: 'BookOpen', label: 'Book', component: BookOpen },
|
||||
{ value: 'Edit', label: 'Edit', component: Edit },
|
||||
|
||||
// Analytics & Charts
|
||||
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
|
||||
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
|
||||
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
|
||||
{ value: 'Target', label: 'Target', component: Target },
|
||||
|
||||
// Database & Storage
|
||||
{ value: 'Database', label: 'Database', component: Database },
|
||||
{ value: 'Server', label: 'Server', component: Server },
|
||||
{ value: 'Cloud', label: 'Cloud', component: Cloud },
|
||||
{ value: 'Folder', label: 'Folder', component: Folder },
|
||||
|
||||
// Marketing & Communication
|
||||
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
|
||||
{ value: 'Mail', label: 'Mail', component: Mail },
|
||||
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
|
||||
{ value: 'Phone', label: 'Phone', component: Phone },
|
||||
{ value: 'Bell', label: 'Bell', component: Bell },
|
||||
|
||||
// Sales & Finance
|
||||
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
|
||||
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
|
||||
{ value: 'Calculator', label: 'Calculator', component: Calculator },
|
||||
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
|
||||
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
|
||||
|
||||
// Support & Service
|
||||
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
|
||||
{ value: 'User', label: 'User', component: User },
|
||||
{ value: 'Users', label: 'Users', component: Users },
|
||||
{ value: 'Settings', label: 'Settings', component: Settings },
|
||||
{ value: 'Wrench', label: 'Wrench', component: Wrench },
|
||||
|
||||
// AI & Technology
|
||||
{ value: 'Bot', label: 'Bot', component: Bot },
|
||||
{ value: 'Brain', label: 'Brain', component: Brain },
|
||||
{ value: 'Cpu', label: 'CPU', component: Cpu },
|
||||
{ value: 'Code', label: 'Code', component: Code },
|
||||
{ value: 'Zap', label: 'Zap', component: Zap },
|
||||
|
||||
// Workflow & Process
|
||||
{ value: 'Workflow', label: 'Workflow', component: Workflow },
|
||||
{ value: 'Search', label: 'Search', component: Search },
|
||||
{ value: 'Play', label: 'Play', component: Play },
|
||||
{ value: 'Layers', label: 'Layers', component: Layers },
|
||||
|
||||
// General
|
||||
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
|
||||
{ value: 'Star', label: 'Star', component: Star },
|
||||
{ value: 'Globe', label: 'Globe', component: Globe },
|
||||
{ value: 'Award', label: 'Award', component: Award },
|
||||
]
|
||||
|
||||
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
category: '',
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
logger.error('User not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Create the template state from current workflow using the same format as deployment
|
||||
const templateState = buildWorkflowStateForTemplate(workflowId)
|
||||
|
||||
const templateData = {
|
||||
workflowId,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
author: data.author,
|
||||
category: data.category,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
state: templateState,
|
||||
}
|
||||
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create template')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info('Template created successfully:', result)
|
||||
|
||||
// Reset form and close modal
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
|
||||
// TODO: Show success toast/notification
|
||||
} catch (error) {
|
||||
logger.error('Failed to create template:', error)
|
||||
// TODO: Show error toast/notification
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const SelectedIconComponent =
|
||||
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex gap-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
|
||||
field.value === icon.value &&
|
||||
'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter template name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='resize-none'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className='mt-auto border-t px-6 pt-4 pb-6'>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'font-medium',
|
||||
'bg-[#701FFC] hover:bg-[#6518E6]',
|
||||
'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none',
|
||||
'h-10 rounded-md px-4 py-2'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Publishing...
|
||||
</>
|
||||
) : (
|
||||
'Publish'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,648 +0,0 @@
|
||||
// NOTE: API NOTIFICATIONS NO LONGER EXIST, BUT IF YOU DELETE THEM FROM THIS FILE THE APPLICATION WILL BREAK
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info, Rocket, Store, Terminal, X } from 'lucide-react'
|
||||
import { ErrorIcon } from '@/components/icons'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MAX_VISIBLE_NOTIFICATIONS, useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { Notification } from '@/stores/notifications/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Notifications')
|
||||
|
||||
// Constants
|
||||
const FADE_DURATION = 500 // Fade out over 500ms
|
||||
|
||||
// Define keyframes for the animations in a style tag
|
||||
const AnimationStyles = () => (
|
||||
<style jsx global>{`
|
||||
@keyframes notification-slide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-slide-up {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-notification-slide {
|
||||
animation: notification-slide 300ms ease forwards;
|
||||
}
|
||||
|
||||
.animate-notification-fade-out {
|
||||
animation: notification-fade-out ${FADE_DURATION}ms ease forwards;
|
||||
}
|
||||
|
||||
.animate-notification-slide-up {
|
||||
animation: notification-slide-up 300ms ease forwards;
|
||||
}
|
||||
|
||||
.notification-container {
|
||||
transition:
|
||||
height 300ms ease,
|
||||
opacity 300ms ease,
|
||||
transform 300ms ease;
|
||||
}
|
||||
`}</style>
|
||||
)
|
||||
|
||||
// Icon mapping for notification types
|
||||
const NotificationIcon = {
|
||||
error: ErrorIcon,
|
||||
console: Terminal,
|
||||
api: Rocket,
|
||||
marketplace: Store,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
// Color schemes for different notification types
|
||||
const NotificationColors = {
|
||||
error:
|
||||
'border-red-500 bg-red-50 text-destructive dark:border-border dark:text-foreground dark:bg-background',
|
||||
console:
|
||||
'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
|
||||
api: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
|
||||
marketplace:
|
||||
'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
|
||||
info: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
|
||||
}
|
||||
|
||||
// API deployment status styling
|
||||
const ApiStatusStyles = {
|
||||
active: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400',
|
||||
inactive: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400',
|
||||
}
|
||||
|
||||
/**
|
||||
* AlertDialog component for API deletion confirmation
|
||||
*/
|
||||
function DeleteApiConfirmation({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
workflowId,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
workflowId: string | null
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||
<AlertDialogContent className='z-[100]'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete API Deployment</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this API deployment? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationList component displays all active notifications as alerts
|
||||
* with support for auto-dismissal, copying content, and different styling per type
|
||||
*/
|
||||
export function NotificationList() {
|
||||
// Store access
|
||||
const {
|
||||
notifications,
|
||||
hideNotification,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
setNotificationFading,
|
||||
getVisibleNotificationCount,
|
||||
} = useNotificationStore()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// Local state
|
||||
const [removedIds, setRemovedIds] = useState<Set<string>>(new Set())
|
||||
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Filter to only show:
|
||||
// 1. Visible notifications for the current workflow
|
||||
// 2. That are either unread OR marked as persistent
|
||||
// 3. And have not been marked for removal
|
||||
const visibleNotifications = notifications.filter(
|
||||
(n) =>
|
||||
n.isVisible &&
|
||||
n.workflowId === activeWorkflowId &&
|
||||
(!n.read || n.options?.isPersistent) &&
|
||||
!removedIds.has(n.id)
|
||||
)
|
||||
|
||||
// Check if we're over the limit of visible notifications
|
||||
const visibleCount = activeWorkflowId ? getVisibleNotificationCount(activeWorkflowId) : 0
|
||||
const _isOverLimit = visibleCount > MAX_VISIBLE_NOTIFICATIONS
|
||||
|
||||
// Reset removedIds whenever a notification's visibility changes from false to true
|
||||
useEffect(() => {
|
||||
const newlyVisibleNotifications = notifications.filter(
|
||||
(n) => n.isVisible && removedIds.has(n.id)
|
||||
)
|
||||
|
||||
if (newlyVisibleNotifications.length > 0) {
|
||||
setRemovedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
newlyVisibleNotifications.forEach((n) => next.delete(n.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [notifications, removedIds])
|
||||
|
||||
// Handle fading notifications created by the store
|
||||
useEffect(() => {
|
||||
// This effect watches for notifications that are fading
|
||||
// and handles the DOM removal after animation completes
|
||||
|
||||
const timers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
visibleNotifications.forEach((notification) => {
|
||||
// For notifications that have started fading, set up cleanup timers
|
||||
if (notification.isFading && !animatingIds.has(notification.id)) {
|
||||
// Start slide up animation after fade animation
|
||||
const slideTimer = setTimeout(() => {
|
||||
setAnimatingIds((prev) => new Set([...prev, notification.id]))
|
||||
|
||||
// After slide animation, remove from DOM
|
||||
setTimeout(() => {
|
||||
hideNotification(notification.id)
|
||||
markAsRead(notification.id)
|
||||
setRemovedIds((prev) => new Set([...prev, notification.id]))
|
||||
|
||||
// Remove from animating set
|
||||
setAnimatingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(notification.id)
|
||||
return next
|
||||
})
|
||||
}, 300)
|
||||
}, FADE_DURATION)
|
||||
|
||||
timers[notification.id] = slideTimer
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
Object.values(timers).forEach(clearTimeout)
|
||||
}
|
||||
}, [visibleNotifications, animatingIds, hideNotification, markAsRead])
|
||||
|
||||
// Early return if no notifications to show
|
||||
if (visibleNotifications.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimationStyles />
|
||||
<div
|
||||
className='pointer-events-none absolute left-1/2 z-[60] w-full max-w-lg space-y-2'
|
||||
style={{
|
||||
top: '30px',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{visibleNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'notification-container',
|
||||
animatingIds.has(notification.id) && 'animate-notification-slide-up'
|
||||
)}
|
||||
>
|
||||
<NotificationAlert
|
||||
notification={notification}
|
||||
isFading={notification.isFading ?? false}
|
||||
onHide={(id) => {
|
||||
// For persistent notifications like API, just hide immediately without animations
|
||||
if (notification.options?.isPersistent) {
|
||||
hideNotification(id)
|
||||
markAsRead(id)
|
||||
setRemovedIds((prev) => new Set([...prev, id]))
|
||||
return
|
||||
}
|
||||
|
||||
// For regular notifications, use the animation sequence
|
||||
// Start the fade out animation when manually closing
|
||||
setNotificationFading(id)
|
||||
|
||||
// Start slide up animation after fade completes
|
||||
setTimeout(() => {
|
||||
setAnimatingIds((prev) => new Set([...prev, id]))
|
||||
}, FADE_DURATION)
|
||||
|
||||
// Remove from DOM after all animations complete
|
||||
setTimeout(() => {
|
||||
hideNotification(id)
|
||||
markAsRead(id)
|
||||
setRemovedIds((prev) => new Set([...prev, id]))
|
||||
setAnimatingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, FADE_DURATION + 300) // Fade + slide durations
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual notification alert component
|
||||
*/
|
||||
interface NotificationAlertProps {
|
||||
notification: Notification
|
||||
isFading: boolean
|
||||
onHide: (id: string) => void
|
||||
}
|
||||
|
||||
export function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) {
|
||||
const { id, type, message, options, workflowId } = notification
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const { setDeploymentStatus } = useWorkflowRegistry()
|
||||
|
||||
// Get deployment status from registry using notification's workflowId, not activeWorkflowId
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(workflowId || null)
|
||||
)
|
||||
const isDeployed = deploymentStatus?.isDeployed || false
|
||||
|
||||
// Create a function to clear the redeployment flag and update deployment status
|
||||
const updateDeploymentStatus = (isDeployed: boolean, deployedAt?: Date) => {
|
||||
// Update deployment status in workflow store
|
||||
setDeploymentStatus(workflowId || null, isDeployed, deployedAt)
|
||||
|
||||
// Manually update the needsRedeployment flag in workflow store
|
||||
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
|
||||
}
|
||||
|
||||
const Icon = NotificationIcon[type]
|
||||
|
||||
const handleDeleteApi = async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete API deployment')
|
||||
|
||||
// Update deployment status in the store
|
||||
updateDeploymentStatus(false)
|
||||
|
||||
// Close the notification
|
||||
onHide(id)
|
||||
|
||||
// Close the dialog
|
||||
setIsDeleteDialogOpen(false)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting API deployment:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Function to mask API key with asterisks but keep first and last 4 chars visible
|
||||
const maskApiKey = (key: string) => {
|
||||
if (!key || key.includes('No API key found')) return key
|
||||
if (key.length <= 8) return key
|
||||
return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}`
|
||||
}
|
||||
|
||||
// Modify the curl command to use a placeholder for the API key
|
||||
const formatCurlCommand = (command: string, apiKey: string) => {
|
||||
if (!command.includes('curl')) return command
|
||||
|
||||
// Replace the actual API key with a placeholder in the command
|
||||
const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY')
|
||||
|
||||
// Format the command with line breaks for better readability
|
||||
return sanitizedCommand
|
||||
.replace(' -H ', '\n -H ')
|
||||
.replace(' -d ', '\n -d ')
|
||||
.replace(' http', '\n http')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
className={cn(
|
||||
'pointer-events-auto translate-y-[-100%] opacity-0 transition-all duration-300 ease-in-out',
|
||||
isFading
|
||||
? 'pointer-events-none animate-notification-fade-out'
|
||||
: 'animate-notification-slide',
|
||||
NotificationColors[type]
|
||||
)}
|
||||
>
|
||||
{type === 'api' ? (
|
||||
// Special layout for API notifications with equal spacing
|
||||
<div className='relative flex items-start py-1'>
|
||||
{/* Left icon */}
|
||||
<div className='mt-0.5 flex-shrink-0'>
|
||||
<Icon className='!text-[#802FFF] h-4 w-4' />
|
||||
</div>
|
||||
|
||||
{/* Content area with equal margins */}
|
||||
<div className='mx-4 flex-1 space-y-2 pt-[3.5px] pr-4'>
|
||||
<AlertTitle className='-mt-0.5'>
|
||||
<span>API</span>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className='space-y-4'>
|
||||
<p>{!isDeployed ? 'Workflow currently not deployed' : message}</p>
|
||||
|
||||
{/* Optional sections with copyable content */}
|
||||
{options?.sections?.map((section, index) => {
|
||||
// Get the API key from the sections to use in curl command formatting
|
||||
const apiKey =
|
||||
options.sections?.find((s) => s.label === 'x-api-key')?.content || ''
|
||||
|
||||
return (
|
||||
<div key={index} className='space-y-1.5'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{section.label}
|
||||
</div>
|
||||
|
||||
{/* Copyable code block */}
|
||||
<div className='group relative rounded-md border bg-muted/50 transition-colors hover:bg-muted/80'>
|
||||
{section.label === 'x-api-key' ? (
|
||||
<>
|
||||
<pre
|
||||
className='cursor-pointer overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
title={
|
||||
showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
|
||||
}
|
||||
>
|
||||
{showApiKey ? section.content : maskApiKey(section.content)}
|
||||
</pre>
|
||||
<div className='overflow-x-auto whitespace-pre-wrap font-mono text-xs'>
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</div>
|
||||
</>
|
||||
) : section.label === 'Example curl command' ? (
|
||||
<>
|
||||
<pre className='overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
|
||||
{formatCurlCommand(section.content, apiKey)}
|
||||
</pre>
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<pre className='overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
|
||||
{section.content}
|
||||
</pre>
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Status and Delete button row - with pulsing green indicator */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-muted-foreground text-xs'>Status:</span>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<div className='relative flex items-center justify-center'>
|
||||
{isDeployed ? (
|
||||
options?.needsRedeployment ? (
|
||||
<>
|
||||
<div className='absolute h-3 w-3 animate-ping rounded-full bg-amber-500/20' />
|
||||
<div className='relative h-2 w-2 rounded-full bg-amber-500' />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='absolute h-3 w-3 animate-ping rounded-full bg-green-500/20' />
|
||||
<div className='relative h-2 w-2 rounded-full bg-green-500' />
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className='absolute h-3 w-3 animate-ping rounded-full bg-red-500/20' />
|
||||
<div className='relative h-2 w-2 rounded-full bg-red-500' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-xs',
|
||||
isDeployed
|
||||
? options?.needsRedeployment
|
||||
? 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
: ApiStatusStyles.active
|
||||
: ApiStatusStyles.inactive
|
||||
)}
|
||||
>
|
||||
{isDeployed
|
||||
? options?.needsRedeployment
|
||||
? 'Changes Detected'
|
||||
: 'Active'
|
||||
: 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
{options?.needsRedeployment && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 px-2.5 font-medium text-muted-foreground text-xs hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400'
|
||||
onClick={async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
// Call the deploy endpoint to redeploy the workflow
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to redeploy workflow')
|
||||
|
||||
// Get the response data
|
||||
const data = await response.json()
|
||||
|
||||
// Update deployment status in the store (resets needsRedeployment flag)
|
||||
updateDeploymentStatus(
|
||||
data.isDeployed,
|
||||
data.deployedAt ? new Date(data.deployedAt) : undefined
|
||||
)
|
||||
|
||||
// First close this notification
|
||||
onHide(id)
|
||||
|
||||
// Show a temporary success notification without creating another API notification
|
||||
useNotificationStore
|
||||
.getState()
|
||||
.addNotification(
|
||||
'info',
|
||||
'Workflow successfully redeployed',
|
||||
workflowId,
|
||||
{ isPersistent: false }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
}
|
||||
}}
|
||||
>
|
||||
Redeploy
|
||||
</Button>
|
||||
)}
|
||||
{isDeployed && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 px-2.5 font-medium text-muted-foreground text-xs hover:bg-destructive/10 hover:text-destructive'
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{/* Absolute positioned close button in the top right */}
|
||||
{options?.isPersistent && (
|
||||
<div className='absolute top-0.5 right-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'
|
||||
onClick={() => onHide(id)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Original layout for error, console and marketplace notifications
|
||||
<div className='flex items-start gap-4 py-1'>
|
||||
{/* Icon with proper vertical alignment */}
|
||||
<div className='mt-0.5 flex-shrink-0'>
|
||||
<Icon
|
||||
className={cn('h-4 w-4', {
|
||||
'!text-red-500 mt-[-3px]': type === 'error',
|
||||
'mt-[-4px] text-foreground': type === 'console' || type === 'info',
|
||||
'text-foreground': type === 'marketplace',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content area with right margin for balance */}
|
||||
<div className='mr-4 flex-1 space-y-2'>
|
||||
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
|
||||
<span>
|
||||
{type === 'error'
|
||||
? 'Error'
|
||||
: type === 'marketplace'
|
||||
? 'Marketplace'
|
||||
: type === 'info'
|
||||
? 'Info'
|
||||
: 'Console'}
|
||||
</span>
|
||||
|
||||
{/* Close button for persistent notifications */}
|
||||
{options?.isPersistent && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'
|
||||
onClick={() => onHide(id)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
)}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className='space-y-4'>
|
||||
{/* Message with auto-expanding and max height */}
|
||||
<p className='max-h-[300px] overflow-hidden whitespace-normal break-words break-all'>
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Optional sections with copyable content */}
|
||||
{options?.sections?.map((section, index) => (
|
||||
<div key={index} className='space-y-1.5'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>{section.label}</div>
|
||||
|
||||
{/* Copyable code block with max height */}
|
||||
<div className='group relative rounded-md border bg-muted/50 transition-colors hover:bg-muted/80'>
|
||||
<pre className='max-h-[300px] overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
|
||||
{section.content}
|
||||
</pre>
|
||||
<CopyButton text={section.content} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{/* Delete API confirmation dialog */}
|
||||
<DeleteApiConfirmation
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={handleDeleteApi}
|
||||
workflowId={workflowId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Output Source Dropdown */}
|
||||
<div className='flex-none border-b px-4 py-2'>
|
||||
<div className='flex-none py-2'>
|
||||
<OutputSelect
|
||||
workflowId={activeWorkflowId}
|
||||
selectedOutputs={selectedOutputs}
|
||||
@@ -329,38 +329,38 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Chat messages section - Scrollable area */}
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<ScrollArea className='h-full'>
|
||||
<div>
|
||||
{workflowMessages.length === 0 ? (
|
||||
<div className='flex h-32 items-center justify-center text-muted-foreground text-sm'>
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
No messages yet
|
||||
</div>
|
||||
) : (
|
||||
workflowMessages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} containerWidth={panelWidth} />
|
||||
))
|
||||
)}
|
||||
<ScrollArea className='h-full pb-2' hideScrollbar={true}>
|
||||
<div>
|
||||
{workflowMessages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input section - Fixed height */}
|
||||
<div className='-mt-[1px] relative flex-none border-t bg-background px-4 pt-4 pb-4'>
|
||||
<div className='-mt-[1px] relative flex-nonept-3 pb-4'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder='Type a message...'
|
||||
className='h-10 flex-1 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='h-9 flex-1 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground shadow-xs focus-visible:ring-0 focus-visible:ring-offset-0 dark:border-[#414141] dark:bg-[#202020]'
|
||||
disabled={!activeWorkflowId || isExecuting}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size='icon'
|
||||
disabled={!chatMessage.trim() || !activeWorkflowId || isExecuting}
|
||||
className='h-10 w-10 bg-[#802FFF] text-white hover:bg-[#7028E6]'
|
||||
className='h-9 w-9 rounded-lg bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
<ArrowUp className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { JSONView } from '../../../console/components/json-view/json-view'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: {
|
||||
@@ -11,7 +8,6 @@ interface ChatMessageProps {
|
||||
type: 'user' | 'workflow'
|
||||
isStreaming?: boolean
|
||||
}
|
||||
containerWidth: number
|
||||
}
|
||||
|
||||
// Maximum character length for a word before it's broken up
|
||||
@@ -49,57 +45,42 @@ const WordWrap = ({ text }: { text: string }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, containerWidth }: ChatMessageProps) {
|
||||
const messageDate = useMemo(() => new Date(message.timestamp), [message.timestamp])
|
||||
|
||||
const relativeTime = useMemo(() => {
|
||||
return formatDistanceToNow(messageDate, { addSuffix: true })
|
||||
}, [messageDate])
|
||||
|
||||
// Check if content is a JSON object
|
||||
const isJsonObject = useMemo(() => {
|
||||
return typeof message.content === 'object' && message.content !== null
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
// Format message content as text
|
||||
const formattedContent = useMemo(() => {
|
||||
if (typeof message.content === 'object' && message.content !== null) {
|
||||
return JSON.stringify(message.content, null, 2)
|
||||
}
|
||||
return String(message.content || '')
|
||||
}, [message.content])
|
||||
|
||||
// Format message content based on type
|
||||
const formattedContent = useMemo(() => {
|
||||
if (isJsonObject) {
|
||||
return JSON.stringify(message.content) // Return stringified version for type safety
|
||||
// Render human messages as chat bubbles
|
||||
if (message.type === 'user') {
|
||||
return (
|
||||
<div className='w-full py-2'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div className='rounded-[10px] bg-secondary px-3 py-2'>
|
||||
<div className='whitespace-pre-wrap break-words font-normal text-foreground text-sm leading-normal'>
|
||||
<WordWrap text={formattedContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return String(message.content || '')
|
||||
}, [message.content, isJsonObject])
|
||||
|
||||
// Render agent/workflow messages as full-width text
|
||||
return (
|
||||
<div className='w-full space-y-4 border-border border-b p-4 transition-colors hover:bg-accent/50'>
|
||||
{/* Header with time on left and message type on right */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<Clock className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='text-muted-foreground'>{relativeTime}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
{message.type !== 'user' && <span className='text-muted-foreground'>Workflow</span>}
|
||||
{message.isStreaming && (
|
||||
<span className='ml-2 animate-pulse text-primary' title='Streaming'>
|
||||
•••
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message content with proper word wrapping */}
|
||||
<div className='overflow-wrap-anywhere relative flex-1 whitespace-normal break-normal font-mono text-sm'>
|
||||
{isJsonObject ? (
|
||||
<JSONView data={message.content} initiallyExpanded={false} />
|
||||
) : (
|
||||
<div className='w-full py-2 pl-[2px]'>
|
||||
<div className='overflow-wrap-anywhere relative whitespace-normal break-normal font-normal text-sm leading-normal'>
|
||||
<div className='whitespace-pre-wrap break-words text-foreground'>
|
||||
<WordWrap text={formattedContent} />
|
||||
{message.isStreaming && (
|
||||
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
|
||||
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 shadow-sm dark:bg-primary/10'>
|
||||
<div className='whitespace-pre-wrap break-words text-[#0D0D0D] text-base leading-relaxed dark:text-white'>
|
||||
{isJsonObject ? (
|
||||
<JSONView data={message.content} initiallyExpanded={false} />
|
||||
<JSONView data={message.content} />
|
||||
) : (
|
||||
<span>{message.content}</span>
|
||||
)}
|
||||
@@ -50,11 +50,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
|
||||
<div className='flex'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div className='whitespace-pre-wrap break-words text-base leading-relaxed'>
|
||||
{isJsonObject ? (
|
||||
<JSONView data={message.content} initiallyExpanded={false} />
|
||||
) : (
|
||||
<span>{message.content}</span>
|
||||
)}
|
||||
{isJsonObject ? <JSONView data={message.content} /> : <span>{message.content}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
@@ -289,19 +288,19 @@ export function OutputSelect({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<div className='relative w-full' ref={dropdownRef}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
className={`flex h-9 w-full items-center justify-between rounded-[8px] border px-3 py-1.5 font-normal text-sm shadow-xs transition-colors ${
|
||||
isOutputDropdownOpen
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
? 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground dark:border-[#414141] dark:bg-[#202020]'
|
||||
: 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020]'
|
||||
}`}
|
||||
disabled={workflowOutputs.length === 0 || disabled}
|
||||
>
|
||||
{selectedOutputInfo ? (
|
||||
<div className='flex w-[calc(100%-24px)] items-center gap-2 overflow-hidden'>
|
||||
<div className='flex w-[calc(100%-24px)] items-center gap-2 overflow-hidden text-left'>
|
||||
<div
|
||||
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
@@ -315,10 +314,12 @@ export function OutputSelect({
|
||||
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='truncate'>{selectedOutputsDisplayText}</span>
|
||||
<span className='truncate text-left'>{selectedOutputsDisplayText}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className='w-[calc(100%-24px)] truncate'>{selectedOutputsDisplayText}</span>
|
||||
<span className='w-[calc(100%-24px)] truncate text-left'>
|
||||
{selectedOutputsDisplayText}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 flex-shrink-0 transition-transform ${
|
||||
@@ -328,11 +329,11 @@ export function OutputSelect({
|
||||
</button>
|
||||
|
||||
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
|
||||
<div className='absolute z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover pt-1 shadow-md'>
|
||||
<div className='max-h-[240px] overflow-y-auto'>
|
||||
<div className='absolute left-0 z-50 mt-1 w-full overflow-hidden rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] pt-1 shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
|
||||
<div className='max-h-[230px] overflow-y-auto'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
|
||||
<div key={blockName}>
|
||||
<div className='border-t px-2 pt-1.5 pb-0.5 font-medium text-muted-foreground text-xs first:border-t-0'>
|
||||
<div className='border-[#E5E5E5] border-t px-3 pt-1.5 pb-0.5 font-normal text-muted-foreground text-xs first:border-t-0 dark:border-[#414141]'>
|
||||
{blockName}
|
||||
</div>
|
||||
<div>
|
||||
@@ -342,20 +343,11 @@ export function OutputSelect({
|
||||
key={output.id}
|
||||
onClick={() => handleOutputSelection(output.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left font-normal text-sm',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<div className='flex h-5 w-5 flex-shrink-0 items-center justify-center'>
|
||||
{selectedOutputs.includes(output.id) ? (
|
||||
<div className='flex h-4 w-4 items-center justify-center rounded bg-primary'>
|
||||
<Check className='h-3 w-3 text-white' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-4 w-4 rounded border border-input' />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
@@ -366,25 +358,16 @@ export function OutputSelect({
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='max-w-[calc(100%-48px)] truncate'>{output.path}</span>
|
||||
<span className='flex-1 truncate'>{output.path}</span>
|
||||
{selectedOutputs.includes(output.id) && (
|
||||
<Check className='h-4 w-4 flex-shrink-0 text-primary' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Done button to close dropdown */}
|
||||
<div className='border-t p-2'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => setIsOutputDropdownOpen(false)}
|
||||
className='w-full bg-secondary/80 text-secondary-foreground hover:bg-secondary/90'
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Download, Pause, Play } from 'lucide-react'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('AudioPlayer')
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioUrl: string
|
||||
}
|
||||
|
||||
export function AudioPlayer({ audioUrl }: AudioPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(audioUrl)
|
||||
|
||||
audioRef.current.addEventListener('ended', () => setIsPlaying(false))
|
||||
audioRef.current.addEventListener('pause', () => setIsPlaying(false))
|
||||
audioRef.current.addEventListener('play', () => setIsPlaying(true))
|
||||
audioRef.current.addEventListener('timeupdate', updateProgress)
|
||||
} else {
|
||||
audioRef.current.src = audioUrl
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeEventListener('ended', () => setIsPlaying(false))
|
||||
audioRef.current.removeEventListener('pause', () => setIsPlaying(false))
|
||||
audioRef.current.removeEventListener('play', () => setIsPlaying(true))
|
||||
audioRef.current.removeEventListener('timeupdate', updateProgress)
|
||||
}
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
const updateProgress = () => {
|
||||
if (audioRef.current) {
|
||||
const value = (audioRef.current.currentTime / audioRef.current.duration) * 100
|
||||
setProgress(Number.isNaN(value) ? 0 : value)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAudio = async () => {
|
||||
try {
|
||||
const response = await fetch(audioUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `tts-audio-${Date.now()}.mp3`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
logger.error('Error downloading audio:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const seekAudio = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
const container = e.currentTarget
|
||||
const rect = container.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percent = x / rect.width
|
||||
|
||||
audioRef.current.currentTime = percent * audioRef.current.duration
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-2 flex w-full max-w-xs items-center gap-2 rounded-md bg-background/40 p-2'>
|
||||
<button
|
||||
className='inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors hover:bg-primary/20'
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className='h-3.5 w-3.5' /> : <Play className='ml-0.5 h-3.5 w-3.5' />}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='h-1.5 flex-grow cursor-pointer overflow-hidden rounded-full bg-muted'
|
||||
onClick={seekAudio}
|
||||
>
|
||||
<div className='h-full rounded-full bg-primary/40' style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
className='inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
||||
onClick={downloadAudio}
|
||||
aria-label='Download audio'
|
||||
>
|
||||
<Download className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
// Add dark mode fix for Prism.js
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleId = 'console-code-dark-mode-fix'
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
.dark .token.operator {
|
||||
color: #9cdcfe !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.dark .token.punctuation {
|
||||
color: #d4d4d4 !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeDisplayProps {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export const CodeDisplay = ({ code, language = 'javascript' }: CodeDisplayProps) => {
|
||||
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Calculate sidebar width based on number of lines
|
||||
const lineCount = code.split('\n').length
|
||||
const sidebarWidth = Math.max(30, lineCount.toString().length * 8 + 16) // 8px per digit + 16px padding
|
||||
|
||||
// Calculate visual line heights similar to code.tsx
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const calculateVisualLines = () => {
|
||||
const preElement = containerRef.current?.querySelector('pre')
|
||||
if (!preElement) return
|
||||
|
||||
const lines = code.split('\n')
|
||||
const newVisualLineHeights: number[] = []
|
||||
|
||||
const tempContainer = document.createElement('div')
|
||||
tempContainer.style.cssText = `
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: auto;
|
||||
width: ${preElement.clientWidth}px;
|
||||
font-family: ${window.getComputedStyle(preElement).fontFamily};
|
||||
font-size: ${window.getComputedStyle(preElement).fontSize};
|
||||
line-height: 21px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
`
|
||||
document.body.appendChild(tempContainer)
|
||||
|
||||
lines.forEach((line) => {
|
||||
const lineDiv = document.createElement('div')
|
||||
lineDiv.textContent = line || ' '
|
||||
|
||||
tempContainer.appendChild(lineDiv)
|
||||
const actualHeight = lineDiv.getBoundingClientRect().height
|
||||
const lineUnits = Math.max(1, Math.ceil(actualHeight / 21))
|
||||
newVisualLineHeights.push(lineUnits)
|
||||
tempContainer.removeChild(lineDiv)
|
||||
})
|
||||
|
||||
document.body.removeChild(tempContainer)
|
||||
setVisualLineHeights(newVisualLineHeights)
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(calculateVisualLines, 50)
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisualLines)
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [code, sidebarWidth])
|
||||
|
||||
// Render line numbers with proper visual line handling
|
||||
const renderLineNumbers = () => {
|
||||
const numbers: React.ReactElement[] = []
|
||||
let lineNumber = 1
|
||||
|
||||
visualLineHeights.forEach((height, index) => {
|
||||
numbers.push(
|
||||
<div key={`${lineNumber}-0`} className='text-muted-foreground text-xs leading-[21px]'>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)
|
||||
for (let i = 1; i < height; i++) {
|
||||
numbers.push(
|
||||
<div
|
||||
key={`${lineNumber}-${i}`}
|
||||
className='invisible text-muted-foreground text-xs leading-[21px]'
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
lineNumber++
|
||||
})
|
||||
|
||||
if (numbers.length === 0) {
|
||||
const lines = code.split('\n')
|
||||
return lines.map((_, index) => (
|
||||
<div key={index} className='text-muted-foreground text-xs leading-[21px]'>
|
||||
{index + 1}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return numbers
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative overflow-hidden rounded-md border bg-background font-mono text-sm'
|
||||
ref={containerRef}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<div
|
||||
className='absolute top-0 bottom-0 left-0 flex select-none flex-col items-end overflow-hidden bg-secondary pt-2 pr-3'
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
aria-hidden='true'
|
||||
>
|
||||
{renderLineNumbers()}
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className='relative' style={{ paddingLeft: `${sidebarWidth}px` }}>
|
||||
<pre
|
||||
className='max-w-full overflow-hidden px-3 py-2 text-sm leading-[21px]'
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
<code
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(code, languages[language], language),
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +1,603 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Terminal,
|
||||
Clipboard,
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { ConsoleEntry as ConsoleEntryType } from '@/stores/panel/console/types'
|
||||
import { CodeDisplay } from '../code-display/code-display'
|
||||
import { JSONView } from '../json-view/json-view'
|
||||
|
||||
const logger = createLogger('ConsoleEntry')
|
||||
|
||||
interface ConsoleEntryProps {
|
||||
entry: ConsoleEntryType
|
||||
consoleWidth: number
|
||||
}
|
||||
|
||||
// Maximum character length for a word before it's broken up
|
||||
const MAX_WORD_LENGTH = 25
|
||||
|
||||
const WordWrap = ({ text }: { text: string }) => {
|
||||
if (!text) return null
|
||||
|
||||
// Split text into words, keeping spaces and punctuation
|
||||
const parts = text.split(/(\s+)/g)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// If the part is whitespace or shorter than the max length, render it as is
|
||||
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
|
||||
return <span key={index}>{part}</span>
|
||||
// Helper function to check if an object contains an audio URL
|
||||
const hasAudioData = (obj: any): boolean => {
|
||||
return obj && typeof obj === 'object' && 'audioUrl' in obj && typeof obj.audioUrl === 'string'
|
||||
}
|
||||
|
||||
// For long words, break them up into chunks
|
||||
const chunks = []
|
||||
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
|
||||
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
|
||||
// Helper function to check if a string is likely a base64 image
|
||||
const isBase64Image = (str: string): boolean => {
|
||||
if (typeof str !== 'string') return false
|
||||
return str.length > 100 && /^[A-Za-z0-9+/=]+$/.test(str)
|
||||
}
|
||||
|
||||
// Helper function to check if an object contains an image URL
|
||||
const hasImageData = (obj: any): boolean => {
|
||||
if (!obj || typeof obj !== 'object') return false
|
||||
|
||||
// Case 1: Has explicit image data (base64)
|
||||
if (
|
||||
'image' in obj &&
|
||||
typeof obj.image === 'string' &&
|
||||
obj.image.length > 0 &&
|
||||
isBase64Image(obj.image)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Case 2: Has explicit image type in metadata
|
||||
if (
|
||||
obj.metadata?.type &&
|
||||
typeof obj.metadata.type === 'string' &&
|
||||
obj.metadata.type.toLowerCase() === 'image'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Case 3: Content URL points to an image file
|
||||
if (typeof obj.content === 'string' && obj.content.startsWith('http')) {
|
||||
return !!obj.content.toLowerCase().match(/\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/)
|
||||
}
|
||||
|
||||
// Case 4: Has URL property with image extension
|
||||
if ('url' in obj && typeof obj.url === 'string') {
|
||||
if (obj.metadata?.fileType?.startsWith('image/')) {
|
||||
return true
|
||||
}
|
||||
const url = obj.url.toLowerCase()
|
||||
return url.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?|$)/) !== null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Get image URL from object
|
||||
const getImageUrl = (obj: any): string | null => {
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
|
||||
// Try content field first
|
||||
if (typeof obj.content === 'string' && obj.content.startsWith('http')) {
|
||||
return obj.content
|
||||
}
|
||||
|
||||
// Try url field
|
||||
if ('url' in obj && typeof obj.url === 'string') {
|
||||
return obj.url
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Get base64 image data from object
|
||||
const getImageData = (obj: any): string | null => {
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
|
||||
if (
|
||||
'image' in obj &&
|
||||
typeof obj.image === 'string' &&
|
||||
obj.image.length > 0 &&
|
||||
isBase64Image(obj.image)
|
||||
) {
|
||||
return obj.image
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Image preview component
|
||||
const ImagePreview = ({
|
||||
imageUrl,
|
||||
imageData,
|
||||
isBase64 = false,
|
||||
onLoadError,
|
||||
}: {
|
||||
imageUrl?: string
|
||||
imageData?: string
|
||||
isBase64?: boolean
|
||||
onLoadError?: (hasError: boolean) => void
|
||||
}) => {
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
// Only display image if we have valid data
|
||||
const hasValidData =
|
||||
(isBase64 && imageData && imageData.length > 0) || (imageUrl && imageUrl.length > 0)
|
||||
|
||||
if (!hasValidData) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Determine the source for the image
|
||||
const imageSrc =
|
||||
isBase64 && imageData && imageData.length > 0
|
||||
? `data:image/png;base64,${imageData}`
|
||||
: imageUrl || ''
|
||||
|
||||
return (
|
||||
<span key={index} className='break-all'>
|
||||
{chunks.map((chunk, chunkIndex) => (
|
||||
<span key={chunkIndex}>{chunk}</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<div className='my-2 w-1/2'>
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt='Generated image'
|
||||
width={400}
|
||||
height={300}
|
||||
className='h-auto w-full rounded-lg border'
|
||||
unoptimized
|
||||
onError={(e) => {
|
||||
console.error('Image failed to load:', imageSrc)
|
||||
setLoadError(true)
|
||||
onLoadError?.(true)
|
||||
}}
|
||||
onLoad={() => {
|
||||
onLoadError?.(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [expandAllJson, setExpandAllJson] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(true) // Default expanded
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showInput, setShowInput] = useState(false) // State for input/output toggle
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [imageLoadError, setImageLoadError] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// Check if entry has audio data
|
||||
const hasAudio = useMemo(() => {
|
||||
return entry.output != null && hasAudioData(entry.output)
|
||||
}, [entry.output])
|
||||
|
||||
// Check if entry has image data
|
||||
const hasImage = useMemo(() => {
|
||||
return entry.output != null && hasImageData(entry.output)
|
||||
}, [entry.output])
|
||||
|
||||
// Only show image download button if image exists and didn't fail to load
|
||||
const showImageDownload = hasImage && !imageLoadError
|
||||
|
||||
const audioUrl = useMemo(() => {
|
||||
return hasAudio && entry.output ? entry.output.audioUrl : null
|
||||
}, [hasAudio, entry.output])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
return hasImage && entry.output ? getImageUrl(entry.output) : null
|
||||
}, [hasImage, entry.output])
|
||||
|
||||
const imageData = useMemo(() => {
|
||||
return hasImage && entry.output ? getImageData(entry.output) : null
|
||||
}, [hasImage, entry.output])
|
||||
|
||||
const isBase64Image = useMemo(() => {
|
||||
return imageData != null && imageData.length > 0
|
||||
}, [imageData])
|
||||
|
||||
// Get the data to display based on the toggle state
|
||||
const displayData = useMemo(() => {
|
||||
return showInput ? entry.input : entry.output
|
||||
}, [showInput, entry.input, entry.output])
|
||||
|
||||
// Check if input data exists
|
||||
const hasInputData = useMemo(() => {
|
||||
return (
|
||||
entry.input != null &&
|
||||
(typeof entry.input === 'object'
|
||||
? Object.keys(entry.input).length > 0
|
||||
: entry.input !== undefined && entry.input !== null)
|
||||
)
|
||||
}, [entry.input])
|
||||
|
||||
// Check if this is a function block with code input
|
||||
const shouldShowCodeDisplay = useMemo(() => {
|
||||
return (
|
||||
entry.blockType === 'function' &&
|
||||
showInput &&
|
||||
entry.input &&
|
||||
typeof entry.input === 'object' &&
|
||||
'code' in entry.input &&
|
||||
typeof entry.input.code === 'string'
|
||||
)
|
||||
}, [entry.blockType, showInput, entry.input])
|
||||
|
||||
// Audio player logic
|
||||
useEffect(() => {
|
||||
if (!hasAudio || !audioUrl) return
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(audioUrl)
|
||||
audioRef.current.addEventListener('ended', () => setIsPlaying(false))
|
||||
audioRef.current.addEventListener('pause', () => setIsPlaying(false))
|
||||
audioRef.current.addEventListener('play', () => setIsPlaying(true))
|
||||
audioRef.current.addEventListener('timeupdate', updateProgress)
|
||||
} else {
|
||||
audioRef.current.src = audioUrl
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeEventListener('ended', () => setIsPlaying(false))
|
||||
audioRef.current.removeEventListener('pause', () => setIsPlaying(false))
|
||||
audioRef.current.removeEventListener('play', () => setIsPlaying(true))
|
||||
audioRef.current.removeEventListener('timeupdate', updateProgress)
|
||||
}
|
||||
}
|
||||
}, [hasAudio, audioUrl])
|
||||
|
||||
const updateProgress = () => {
|
||||
if (audioRef.current) {
|
||||
const value = (audioRef.current.currentTime / audioRef.current.duration) * 100
|
||||
setProgress(Number.isNaN(value) ? 0 : value)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAudio = async () => {
|
||||
if (!audioUrl) return
|
||||
|
||||
try {
|
||||
const response = await fetch(audioUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `tts-audio-${Date.now()}.mp3`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
logger.error('Error downloading audio:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
let blob: Blob
|
||||
if (isBase64Image && imageData && imageData.length > 0) {
|
||||
// Convert base64 to blob
|
||||
const byteString = atob(imageData)
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
blob = new Blob([arrayBuffer], { type: 'image/png' })
|
||||
} else if (imageUrl && imageUrl.length > 0) {
|
||||
// Use proxy endpoint to fetch image
|
||||
const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(imageUrl)}`
|
||||
const response = await fetch(proxyUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
} else {
|
||||
throw new Error('No image data or URL provided')
|
||||
}
|
||||
|
||||
// Create object URL and trigger download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `generated-image-${Date.now()}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
// Clean up the URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error)
|
||||
alert('Failed to download image. Please try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
const blockConfig = useMemo(() => {
|
||||
if (!entry.blockType) return null
|
||||
return getBlock(entry.blockType)
|
||||
}, [entry.blockType])
|
||||
|
||||
const BlockIcon = blockConfig?.icon
|
||||
const handleCopy = () => {
|
||||
let textToCopy: string
|
||||
|
||||
// Helper function to check if data has nested objects or arrays
|
||||
const hasNestedStructure = (data: any): boolean => {
|
||||
if (data === null || typeof data !== 'object') return false
|
||||
|
||||
// Check if it's an empty object or array
|
||||
if (Object.keys(data).length === 0) return false
|
||||
|
||||
// For arrays, check if any element is an object
|
||||
if (Array.isArray(data)) {
|
||||
return data.some((item) => typeof item === 'object' && item !== null)
|
||||
if (shouldShowCodeDisplay) {
|
||||
// For code display, copy just the code string
|
||||
textToCopy = entry.input.code
|
||||
} else {
|
||||
// For regular JSON display, copy the full JSON
|
||||
const dataToCopy = showInput ? entry.input : entry.output
|
||||
textToCopy = JSON.stringify(dataToCopy, null, 2)
|
||||
}
|
||||
|
||||
// For objects, check if any value is an object
|
||||
return Object.values(data).some((value) => typeof value === 'object' && value !== null)
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
setShowCopySuccess(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showCopySuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowCopySuccess(false)
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showCopySuccess])
|
||||
|
||||
const BlockIcon = blockConfig?.icon
|
||||
const blockColor = blockConfig?.bgColor || '#6B7280'
|
||||
|
||||
// Handle image load error callback
|
||||
const handleImageLoadError = (hasError: boolean) => {
|
||||
setImageLoadError(hasError)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{/* Header: Icon | Block name */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{BlockIcon && (
|
||||
<div
|
||||
className={`border-border border-b transition-colors ${
|
||||
!entry.error && !entry.warning && entry.success ? 'cursor-pointer hover:bg-accent/50' : ''
|
||||
}`}
|
||||
onClick={() => !entry.error && !entry.warning && entry.success && setIsExpanded(!isExpanded)}
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: blockColor }}
|
||||
>
|
||||
<div className='space-y-4 p-4'>
|
||||
<div
|
||||
className={`${
|
||||
consoleWidth >= 400 ? 'flex items-center justify-between' : 'grid grid-cols-1 gap-4'
|
||||
}`}
|
||||
>
|
||||
{entry.blockName && (
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
{BlockIcon ? (
|
||||
<BlockIcon className='h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<Terminal className='h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
<span className='text-muted-foreground'>{entry.blockName}</span>
|
||||
<BlockIcon className='h-3 w-3 text-white' />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
consoleWidth >= 400 ? 'flex gap-4' : 'grid grid-cols-2 gap-4'
|
||||
} text-muted-foreground text-sm`}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
<span>{entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock className='h-4 w-4' />
|
||||
<span>Duration: {entry.durationMs ?? 0}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className='font-normal text-base text-sm leading-normal'>
|
||||
{entry.blockName || 'Unknown Block'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{!entry.error && !entry.warning && (
|
||||
<div className='flex items-start gap-2'>
|
||||
<Terminal className='mt-1 h-4 w-4 text-muted-foreground' />
|
||||
<div className='overflow-wrap-anywhere relative flex-1 whitespace-normal break-normal font-mono text-sm'>
|
||||
{entry.output != null && (
|
||||
<div className='absolute top-0 right-0 z-10'>
|
||||
{/* Duration tag | Time tag | Input/Output tags */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`flex h-5 items-center rounded-lg px-2 ${
|
||||
entry.error ? 'bg-[#F6D2D2] dark:bg-[#442929]' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{entry.error ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<AlertCircle className='h-3 w-3 text-[#DC2626] dark:text-[#F87171]' />
|
||||
<span className='font-normal text-[#DC2626] text-xs leading-normal dark:text-[#F87171]'>
|
||||
Error
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className='font-normal text-muted-foreground text-xs leading-normal'>
|
||||
{entry.durationMs ?? 0}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex h-5 items-center rounded-lg bg-secondary px-2'>
|
||||
<span className='font-normal text-muted-foreground text-xs leading-normal'>
|
||||
{entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Input/Output tags - only show if input data exists */}
|
||||
{hasInputData && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowInput(false)}
|
||||
className={`flex h-5 items-center rounded-lg px-2 transition-colors ${
|
||||
!showInput
|
||||
? 'border-[#e5e5e5] bg-[#f5f5f5] text-[#1a1a1a] dark:border-[#424242] dark:bg-[#1f1f1f] dark:text-[#ffffff]'
|
||||
: 'bg-secondary text-muted-foreground hover:bg-secondary hover:text-card-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className='font-normal text-xs leading-normal'>Output</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className={`flex h-5 items-center rounded-lg px-2 transition-colors ${
|
||||
showInput
|
||||
? 'border-[#e5e5e5] bg-[#f5f5f5] text-[#1a1a1a] dark:border-[#424242] dark:bg-[#1f1f1f] dark:text-[#ffffff]'
|
||||
: 'bg-secondary text-muted-foreground hover:bg-secondary hover:text-card-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className='font-normal text-xs leading-normal'>Input</span>
|
||||
</button>
|
||||
{/* Copy button for code input - only show when input is selected and it's a function block with code */}
|
||||
{shouldShowCodeDisplay && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-muted-foreground hover:text-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandAllJson(!expandAllJson)
|
||||
}}
|
||||
className='h-5 w-5 p-0 hover:bg-transparent'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
{expandAllJson ? (
|
||||
<>
|
||||
<ChevronUp className='mr-1 h-3 w-3' />
|
||||
<span className='text-xs'>Collapse</span>
|
||||
</>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-3 w-3 text-gray-500' />
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className='mr-1 h-3 w-3' />
|
||||
<span className='text-xs'>Expand</span>
|
||||
<Clipboard className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Response area */}
|
||||
<div className='space-y-2 pb-2'>
|
||||
{/* Error display */}
|
||||
{entry.error && !showInput && (
|
||||
<div className='rounded-lg bg-[#F6D2D2] p-3 dark:bg-[#442929]'>
|
||||
<div className='overflow-hidden whitespace-pre-wrap break-all font-normal text-[#DC2626] text-sm leading-normal dark:text-[#F87171]'>
|
||||
{entry.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning display */}
|
||||
{entry.warning && !showInput && (
|
||||
<div className='rounded-lg border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800/50'>
|
||||
<div className='mb-1 font-normal text-sm text-yellow-800 leading-normal dark:text-yellow-200'>
|
||||
Warning
|
||||
</div>
|
||||
<div className='overflow-hidden whitespace-pre-wrap break-all font-normal text-sm text-yellow-700 leading-normal dark:text-yellow-300'>
|
||||
{entry.warning}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content display */}
|
||||
{(showInput ? hasInputData : entry.output != null && !entry.error) && (
|
||||
<div className={`rounded-lg bg-secondary/50 ${shouldShowCodeDisplay ? 'p-0' : 'p-3'}`}>
|
||||
{shouldShowCodeDisplay ? (
|
||||
/* Code display - replace entire content */
|
||||
<CodeDisplay code={entry.input.code} />
|
||||
) : (
|
||||
<div className='relative'>
|
||||
{/* Copy and Expand/Collapse buttons */}
|
||||
<div className='absolute top-[-2.8] right-0 z-10 flex items-center gap-1'>
|
||||
{/* Audio controls - only show if audio data exists and we're showing output */}
|
||||
{hasAudio && !showInput && (
|
||||
<>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-transparent'
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className='h-3 w-3 text-muted-foreground' />
|
||||
) : (
|
||||
<Play className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-transparent'
|
||||
onClick={downloadAudio}
|
||||
aria-label='Download audio'
|
||||
>
|
||||
<Download className='h-3 w-3 text-muted-foreground' />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Image controls - only show if image data exists and didn't fail to load and we're showing output */}
|
||||
{showImageDownload && !showInput && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-transparent'
|
||||
onClick={downloadImage}
|
||||
aria-label='Download image'
|
||||
>
|
||||
<Download className='h-3 w-3 text-muted-foreground' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-transparent'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-3 w-3 text-gray-500' />
|
||||
) : (
|
||||
<Clipboard className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-transparent'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className='h-3 w-3 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronDown className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image preview - show before JSON content - only for output mode */}
|
||||
{hasImage && !showInput && (
|
||||
<ImagePreview
|
||||
imageUrl={imageUrl || undefined}
|
||||
imageData={imageData || undefined}
|
||||
isBase64={isBase64Image}
|
||||
onLoadError={handleImageLoadError}
|
||||
/>
|
||||
)}
|
||||
<JSONView data={entry.output} initiallyExpanded={expandAllJson} />
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded ? (
|
||||
<div className='max-w-full overflow-hidden break-all font-mono font-normal text-muted-foreground text-sm leading-normal'>
|
||||
<JSONView data={displayData} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='max-w-full cursor-pointer overflow-hidden break-all font-mono font-normal text-muted-foreground text-sm leading-normal'
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
{'{...}'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No output message */}
|
||||
{!showInput && entry.output == null && !entry.error && (
|
||||
<div className='rounded-lg bg-secondary/50 p-3'>
|
||||
<div className='text-center font-normal text-muted-foreground text-sm leading-normal'>
|
||||
No output
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className='flex items-start gap-2 rounded-md border border-red-500 bg-red-50 p-3 text-destructive dark:border-border dark:bg-background dark:text-foreground'>
|
||||
<AlertCircle className='mt-1 h-4 w-4 flex-shrink-0 text-red-500' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium'>Error</div>
|
||||
<div className='w-full overflow-hidden whitespace-pre-wrap text-sm'>
|
||||
<WordWrap text={entry.error} />
|
||||
</div>
|
||||
{/* No input message */}
|
||||
{showInput && !hasInputData && (
|
||||
<div className='rounded-lg bg-secondary/50 p-3'>
|
||||
<div className='text-center font-normal text-muted-foreground text-sm leading-normal'>
|
||||
No input
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.warning && (
|
||||
<div className='flex items-start gap-2 rounded-md border border-yellow-500 bg-yellow-50 p-3 text-yellow-700 dark:border-border dark:bg-background dark:text-yellow-500'>
|
||||
<AlertTriangle className='mt-1 h-4 w-4 flex-shrink-0 text-yellow-500' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium'>Warning</div>
|
||||
<div className='w-full overflow-hidden whitespace-pre-wrap text-sm'>
|
||||
<WordWrap text={entry.warning} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AudioPlayer } from '../audio-player/audio-player'
|
||||
|
||||
interface JSONViewProps {
|
||||
data: any
|
||||
level?: number
|
||||
initiallyExpanded?: boolean
|
||||
}
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
const MAX_OBJECT_KEYS = 10
|
||||
const MAX_ARRAY_ITEMS = 20
|
||||
|
||||
const TruncatedValue = ({ value }: { value: string }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (value.length <= MAX_STRING_LENGTH) {
|
||||
return <span>{value}</span>
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>{value}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
{isExpanded ? value : `${value.slice(0, MAX_STRING_LENGTH)}...`}
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-1 text-muted-foreground text-xs hover:text-foreground'
|
||||
className='h-auto px-1 font-[380] text-muted-foreground/70 text-xs hover:text-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
@@ -36,207 +36,124 @@ const TruncatedValue = ({ value }: { value: string }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const CollapsibleJSON = ({ data, depth = 0 }: { data: any; depth?: number }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (data === null) {
|
||||
return <span className='break-all font-[380] text-muted-foreground leading-normal'>null</span>
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>undefined</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return <TruncatedValue value={JSON.stringify(data)} />
|
||||
}
|
||||
|
||||
if (typeof data === 'number' || typeof data === 'boolean') {
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
{JSON.stringify(data)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const shouldCollapse = depth > 0 && data.length > MAX_ARRAY_ITEMS
|
||||
|
||||
if (shouldCollapse && !isExpanded) {
|
||||
return (
|
||||
<span
|
||||
className='cursor-pointer break-all font-[380] text-muted-foreground/70 text-xs leading-normal'
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
{'[...]'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground/70 leading-normal'>
|
||||
{'['}
|
||||
{data.length > 0 && (
|
||||
<>
|
||||
{'\n'}
|
||||
{data.map((item, index) => (
|
||||
<span key={index} className='break-all'>
|
||||
{' '.repeat(depth + 1)}
|
||||
<CollapsibleJSON data={item} depth={depth + 1} />
|
||||
{index < data.length - 1 ? ',' : ''}
|
||||
{'\n'}
|
||||
</span>
|
||||
))}
|
||||
{' '.repeat(depth)}
|
||||
</>
|
||||
)}
|
||||
{']'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data)
|
||||
const shouldCollapse = depth > 0 && keys.length > MAX_OBJECT_KEYS
|
||||
|
||||
if (shouldCollapse && !isExpanded) {
|
||||
return (
|
||||
<span
|
||||
className='cursor-pointer break-all font-[380] text-muted-foreground/70 text-xs leading-normal'
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
{'{...}'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground/70 leading-normal'>
|
||||
{'{'}
|
||||
{keys.length > 0 && (
|
||||
<>
|
||||
{'\n'}
|
||||
{keys.map((key, index) => (
|
||||
<span key={key} className='break-all'>
|
||||
{' '.repeat(depth + 1)}
|
||||
<span className='break-all font-[380] text-foreground leading-normal'>{key}</span>
|
||||
<span className='font-[380] text-muted-foreground/60 leading-normal'>: </span>
|
||||
<CollapsibleJSON data={data[key]} depth={depth + 1} />
|
||||
{index < keys.length - 1 ? ',' : ''}
|
||||
{'\n'}
|
||||
</span>
|
||||
))}
|
||||
{' '.repeat(depth)}
|
||||
</>
|
||||
)}
|
||||
{'}'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
{JSON.stringify(data)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const copyToClipboard = (data: any) => {
|
||||
const stringified = JSON.stringify(data, null, 2)
|
||||
navigator.clipboard.writeText(stringified)
|
||||
}
|
||||
|
||||
// Helper function to check if an object contains an image URL
|
||||
const isImageData = (obj: any): boolean => {
|
||||
if (!obj || typeof obj !== 'object' || !('url' in obj) || typeof obj.url !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we have metadata with file type
|
||||
if (obj.metadata?.fileType) {
|
||||
return obj.metadata.fileType.startsWith('image/')
|
||||
}
|
||||
|
||||
// Fallback to checking URL extension
|
||||
const url = obj.url.toLowerCase()
|
||||
return url.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?|$)/) !== null
|
||||
}
|
||||
|
||||
// Helper function to check if an object contains an audio URL
|
||||
const isAudioData = (obj: any): boolean => {
|
||||
return obj && typeof obj === 'object' && 'audioUrl' in obj && typeof obj.audioUrl === 'string'
|
||||
}
|
||||
|
||||
// Helper function to check if a string is likely a base64 image
|
||||
const isBase64Image = (str: string): boolean => {
|
||||
if (typeof str !== 'string') return false
|
||||
// Check if it's a reasonably long string that could be a base64 image
|
||||
// and contains only valid base64 characters
|
||||
return str.length > 100 && /^[A-Za-z0-9+/=]+$/.test(str)
|
||||
}
|
||||
|
||||
// Check if this is a response with the new image structure
|
||||
// Strict validation to only detect actual image responses
|
||||
const hasImageContent = (obj: any): boolean => {
|
||||
// Debug check - basic structure validation
|
||||
if (
|
||||
!(
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'content' in obj &&
|
||||
typeof obj.content === 'string' &&
|
||||
'metadata' in obj &&
|
||||
typeof obj.metadata === 'object'
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Case 1: Has explicit image data
|
||||
const hasExplicitImageData =
|
||||
'image' in obj &&
|
||||
typeof obj.image === 'string' &&
|
||||
obj.image.length > 0 &&
|
||||
isBase64Image(obj.image)
|
||||
|
||||
if (hasExplicitImageData) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Case 2: Has explicit image type in metadata
|
||||
const hasExplicitImageType =
|
||||
obj.metadata?.type &&
|
||||
typeof obj.metadata.type === 'string' &&
|
||||
obj.metadata.type.toLowerCase() === 'image'
|
||||
|
||||
if (hasExplicitImageType) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Case 3: Content URL points to an image file
|
||||
const isImageUrl =
|
||||
typeof obj.content === 'string' &&
|
||||
obj.content.startsWith('http') &&
|
||||
!!obj.content.toLowerCase().match(/\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/)
|
||||
|
||||
return isImageUrl
|
||||
}
|
||||
|
||||
// Image preview component with support for both URL and base64
|
||||
const ImagePreview = ({
|
||||
imageUrl,
|
||||
imageData,
|
||||
isBase64 = false,
|
||||
}: {
|
||||
imageUrl?: string
|
||||
imageData?: string
|
||||
isBase64?: boolean
|
||||
}) => {
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
let blob: Blob
|
||||
if (isBase64 && imageData && imageData.length > 0) {
|
||||
// Convert base64 to blob
|
||||
const byteString = atob(imageData)
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
blob = new Blob([arrayBuffer], { type: 'image/png' })
|
||||
} else if (imageUrl && imageUrl.length > 0) {
|
||||
// Use proxy endpoint to fetch image
|
||||
const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(imageUrl)}`
|
||||
const response = await fetch(proxyUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
} else {
|
||||
throw new Error('No image data or URL provided')
|
||||
}
|
||||
|
||||
// Create object URL and trigger download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `generated-image-${Date.now()}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
// Clean up the URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error)
|
||||
alert('Failed to download image. Please try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
// Only display image if we have valid data
|
||||
const hasValidData =
|
||||
(isBase64 && imageData && imageData.length > 0) || (imageUrl && imageUrl.length > 0)
|
||||
|
||||
if (!hasValidData) {
|
||||
return <div className='my-2 text-muted-foreground'>Image data unavailable</div>
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return <div className='my-2 text-muted-foreground'>Failed to load image</div>
|
||||
}
|
||||
|
||||
// Determine the source for the image
|
||||
const imageSrc =
|
||||
isBase64 && imageData && imageData.length > 0
|
||||
? `data:image/png;base64,${imageData}`
|
||||
: imageUrl || ''
|
||||
|
||||
return (
|
||||
<div className='group relative my-2'>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt='Generated image'
|
||||
className='h-auto max-w-full rounded-md border'
|
||||
onError={(e) => {
|
||||
console.error('Image failed to load:', imageSrc)
|
||||
setLoadError(true)
|
||||
e.currentTarget.alt = 'Failed to load image'
|
||||
e.currentTarget.style.height = '100px'
|
||||
e.currentTarget.style.width = '100%'
|
||||
e.currentTarget.style.display = 'flex'
|
||||
e.currentTarget.style.alignItems = 'center'
|
||||
e.currentTarget.style.justifyContent = 'center'
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
{!loadError && (
|
||||
<div className='absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='h-8 w-8 bg-background/80 backdrop-blur-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadImage()
|
||||
}}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
<span className='sr-only'>Download image</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONViewProps) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(!initiallyExpanded)
|
||||
export const JSONView = ({ data }: JSONViewProps) => {
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(!initiallyExpanded)
|
||||
}, [initiallyExpanded])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
@@ -250,72 +167,31 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
}
|
||||
}, [contextMenuPosition])
|
||||
|
||||
// Check if this is a base64 image string
|
||||
const isBase64ImageString = typeof data === 'string' && isBase64Image(data)
|
||||
|
||||
// Check if current object contains image URL
|
||||
const hasImageUrl = isImageData(data)
|
||||
|
||||
// Check if current object contains audio URL
|
||||
const hasAudioUrl = isAudioData(data)
|
||||
|
||||
// Check if this is a response object with the new image format
|
||||
const isResponseWithImage = hasImageContent(data)
|
||||
|
||||
// Check if this is response.output with the new image structure
|
||||
const isToolResponseWithImage =
|
||||
data && typeof data === 'object' && data.output && hasImageContent(data.output)
|
||||
|
||||
if (data === null) return <span className='text-muted-foreground'>null</span>
|
||||
|
||||
// Handle base64 image strings directly
|
||||
if (isBase64ImageString) {
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ImagePreview imageData={data} isBase64={true} />
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy base64 string
|
||||
</button>
|
||||
<button
|
||||
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (data === null)
|
||||
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
|
||||
|
||||
// For non-object data, show simple JSON
|
||||
if (typeof data !== 'object') {
|
||||
const stringValue = JSON.stringify(data)
|
||||
return (
|
||||
<span
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`${typeof data === 'string' ? 'text-success' : 'text-info'} relative break-all`}
|
||||
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
|
||||
>
|
||||
{typeof data === 'string' ? <TruncatedValue value={stringValue} /> : stringValue}
|
||||
{typeof data === 'string' ? (
|
||||
<TruncatedValue value={stringValue} />
|
||||
) : (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
{stringValue}
|
||||
</span>
|
||||
)}
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy value
|
||||
@@ -326,307 +202,25 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
)
|
||||
}
|
||||
|
||||
// Handle objects that have the new image structure
|
||||
if (isResponseWithImage) {
|
||||
// Get the URL from content field since that's where it should be
|
||||
const imageUrl = data.content && typeof data.content === 'string' ? data.content : undefined
|
||||
// Check if we have valid image data
|
||||
const hasValidImage = data.image && typeof data.image === 'string' && data.image.length > 0
|
||||
|
||||
// Default case: show JSON as formatted text with collapsible functionality
|
||||
return (
|
||||
<div className='relative' onContextMenu={handleContextMenu}>
|
||||
<span
|
||||
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}}
|
||||
>
|
||||
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>{'{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className='ml-4 break-words'>
|
||||
{Object.entries(data).map(([key, value], index) => {
|
||||
const isImageKey = key === 'image'
|
||||
|
||||
return (
|
||||
<div key={key} className='break-all'>
|
||||
<span className='text-muted-foreground'>{key}</span>:{' '}
|
||||
{isImageKey ? (
|
||||
<div>
|
||||
{/* Show image preview within the image field */}
|
||||
<ImagePreview
|
||||
imageUrl={imageUrl}
|
||||
imageData={
|
||||
hasValidImage && isBase64Image(data.image) ? data.image : undefined
|
||||
}
|
||||
isBase64={hasValidImage && isBase64Image(data.image)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
data={value}
|
||||
level={level + 1}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
)}
|
||||
{index < Object.entries(data).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
|
||||
<CollapsibleJSON data={data} />
|
||||
</pre>
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
<button
|
||||
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className='text-muted-foreground'>{'}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle tool response objects with the new image structure in output
|
||||
if (isToolResponseWithImage) {
|
||||
const outputData = data.output || {}
|
||||
const imageUrl =
|
||||
outputData.content && typeof outputData.content === 'string' ? outputData.content : undefined
|
||||
const hasValidImage =
|
||||
outputData.image && typeof outputData.image === 'string' && outputData.image.length > 0
|
||||
|
||||
return (
|
||||
<div className='relative' onContextMenu={handleContextMenu}>
|
||||
<span
|
||||
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}}
|
||||
>
|
||||
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>{'{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className='ml-4 break-words'>
|
||||
{Object.entries(data).map(([key, value]: [string, any], index) => {
|
||||
const isOutputKey = key === 'output'
|
||||
|
||||
return (
|
||||
<div key={key} className='break-all'>
|
||||
<span className='text-muted-foreground'>{key}</span>:{' '}
|
||||
{isOutputKey ? (
|
||||
<div className='relative'>
|
||||
<span
|
||||
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const nestedElem = e.currentTarget.nextElementSibling
|
||||
if (nestedElem) {
|
||||
nestedElem.classList.toggle('hidden')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='mr-1 text-xs leading-none'>▼</span>
|
||||
<span>{'{'}</span>
|
||||
</span>
|
||||
<div className='ml-4 break-words'>
|
||||
{Object.entries(value).map(
|
||||
([outputKey, outputValue]: [string, any], idx) => {
|
||||
const isImageSubKey = outputKey === 'image'
|
||||
|
||||
return (
|
||||
<div key={outputKey} className='break-all'>
|
||||
<span className='text-muted-foreground'>{outputKey}</span>:{' '}
|
||||
{isImageSubKey ? (
|
||||
<div>
|
||||
{/* Show image preview within nested image field */}
|
||||
<ImagePreview
|
||||
imageUrl={imageUrl}
|
||||
imageData={
|
||||
hasValidImage && isBase64Image(outputValue)
|
||||
? outputValue
|
||||
: undefined
|
||||
}
|
||||
isBase64={hasValidImage && isBase64Image(outputValue)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
data={outputValue}
|
||||
level={level + 2}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
)}
|
||||
{idx < Object.entries(value).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<span className='text-muted-foreground'>{'}'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
data={value}
|
||||
level={level + 1}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
)}
|
||||
{index < Object.entries(data).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
<button
|
||||
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className='text-muted-foreground'>{'}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(data)
|
||||
const items = isArray ? data : Object.entries(data)
|
||||
const isEmpty = items.length === 0
|
||||
|
||||
if (isEmpty) {
|
||||
return <span className='text-muted-foreground'>{isArray ? '[]' : '{}'}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative' onContextMenu={handleContextMenu}>
|
||||
<span
|
||||
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}}
|
||||
>
|
||||
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>{isArray ? '[' : '{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{/* Direct image render for objects with image URLs */}
|
||||
{!isCollapsed && hasImageUrl && <ImagePreview imageUrl={data.url} />}
|
||||
|
||||
{/* Direct audio render for objects with audio URLs */}
|
||||
{!isCollapsed && hasAudioUrl && <AudioPlayer audioUrl={data.audioUrl} />}
|
||||
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
{hasImageUrl && (
|
||||
<button
|
||||
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
Download image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className='ml-4 break-words'>
|
||||
{isArray
|
||||
? items.map((item, index) => (
|
||||
<div key={index} className='break-all'>
|
||||
<JSONView data={item} level={level + 1} initiallyExpanded={initiallyExpanded} />
|
||||
{index < items.length - 1 && ','}
|
||||
</div>
|
||||
))
|
||||
: (items as [string, any][]).map(([key, value], index) => {
|
||||
// Handle the case where we have content (URL) and image (base64) fields
|
||||
const isImageField =
|
||||
key === 'image' && typeof value === 'string' && value.length > 100
|
||||
|
||||
return (
|
||||
<div key={key} className='break-all'>
|
||||
<span className='text-muted-foreground'>{key}</span>:{' '}
|
||||
{isImageField ? (
|
||||
<JSONView
|
||||
data={value}
|
||||
level={level + 1}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
data={value}
|
||||
level={level + 1}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
)}
|
||||
{index < items.length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className='text-muted-foreground'>{isArray ? ']' : '}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,18 +19,20 @@ export function Console({ panelWidth }: ConsoleProps) {
|
||||
}, [entries, activeWorkflowId])
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-full'>
|
||||
<div>
|
||||
<div className='h-full pt-2 pl-[1px]'>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className='flex h-32 items-center justify-center pt-4 text-muted-foreground text-sm'>
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
No console entries
|
||||
</div>
|
||||
) : (
|
||||
filteredEntries.map((entry) => (
|
||||
<ScrollArea className='h-full' hideScrollbar={true}>
|
||||
<div className='space-y-3'>
|
||||
{filteredEntries.map((entry) => (
|
||||
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={panelWidth} />
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -23,11 +22,7 @@ import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable, VariableType } from '@/stores/panel/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface VariablesProps {
|
||||
panelWidth: number
|
||||
}
|
||||
|
||||
export function Variables({ panelWidth }: VariablesProps) {
|
||||
export function Variables() {
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const {
|
||||
variables: storeVariables,
|
||||
@@ -239,110 +234,107 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
}, [workflowVariables])
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-full'>
|
||||
<div className='space-y-3 p-4'>
|
||||
{/* Variables List */}
|
||||
<div className='h-full pt-2'>
|
||||
{workflowVariables.length === 0 ? (
|
||||
<div className='flex h-32 flex-col items-center justify-center pt-4 text-muted-foreground text-sm'>
|
||||
<div className='mb-2'>No variables yet</div>
|
||||
<Button variant='outline' size='sm' className='text-xs' onClick={handleAddVariable}>
|
||||
<Plus className='mr-1 h-3.5 w-3.5' />
|
||||
Add your first variable
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<Button
|
||||
onClick={handleAddVariable}
|
||||
className='h-9 rounded-lg border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 font-normal text-muted-foreground text-sm shadow-xs transition-colors hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020] dark:hover:text-muted-foreground'
|
||||
variant='outline'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-3'>
|
||||
<ScrollArea className='h-full' hideScrollbar={true}>
|
||||
<div className='space-y-4'>
|
||||
{workflowVariables.map((variable) => (
|
||||
<div
|
||||
key={variable.id}
|
||||
className='group flex flex-col space-y-2 rounded-lg border bg-background shadow-sm'
|
||||
>
|
||||
<div className='flex items-center justify-between border-b bg-muted/30 p-3'>
|
||||
<div className='flex flex-1 items-center gap-2'>
|
||||
<div key={variable.id} className='space-y-2'>
|
||||
{/* Header: Variable name | Variable type | Options dropdown */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='!text-md h-9 max-w-40 border-input bg-background focus-visible:ring-1 focus-visible:ring-ring'
|
||||
className='h-9 flex-1 rounded-lg border-none bg-secondary/50 px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
placeholder='Variable name'
|
||||
value={variable.name}
|
||||
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Type selector */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-9 gap-1'>
|
||||
<span className='!font-mono pt-[0.3px] text-sm'>
|
||||
{getTypeIcon(variable.type)}
|
||||
</span>
|
||||
<ChevronDown className='!h-3.5 !w-3.5 text-muted-foreground' />
|
||||
</Button>
|
||||
<div className='flex h-9 w-16 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-secondary/50 px-3'>
|
||||
<span className='font-normal text-sm'>{getTypeIcon(variable.type)}</span>
|
||||
<ChevronDown className='ml-1 h-3 w-3 text-muted-foreground' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>Set variable type</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='end' className='min-w-32'>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'plain' })}
|
||||
className='flex cursor-pointer items-center'
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-mono text-sm'>Abc</div>
|
||||
<span>Plain</span>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>Abc</div>
|
||||
<span className='font-[380]'>Plain</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'number' })}
|
||||
className='flex cursor-pointer items-center'
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-mono text-sm'>123</div>
|
||||
<span>Number</span>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>123</div>
|
||||
<span className='font-[380]'>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'boolean' })}
|
||||
className='flex cursor-pointer items-center'
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-mono text-sm'>0/1</div>
|
||||
<span>Boolean</span>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>0/1</div>
|
||||
<span className='font-[380]'>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'object' })}
|
||||
className='flex cursor-pointer items-center'
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-mono text-sm'>{'{}'}</div>
|
||||
<span>Object</span>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>{'{}'}</div>
|
||||
<span className='font-[380]'>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateVariable(variable.id, { type: 'array' })}
|
||||
className='flex cursor-pointer items-center'
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-mono text-sm'>[]</div>
|
||||
<span>Array</span>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>[]</div>
|
||||
<span className='font-[380]'>Array</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className='flex items-center'>
|
||||
{/* Options dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-9 w-9 text-muted-foreground'
|
||||
size='sm'
|
||||
className='h-9 w-9 shrink-0 rounded-lg bg-secondary/50 p-0 text-muted-foreground hover:bg-secondary/70 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
>
|
||||
<MoreVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => duplicateVariable(variable.id)}
|
||||
className='cursor-pointer text-muted-foreground'
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteVariable(variable.id)}
|
||||
className='cursor-pointer text-destructive focus:text-destructive'
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
@@ -350,22 +342,37 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value area */}
|
||||
<div className='relative rounded-lg bg-secondary/50'>
|
||||
{/* Validation indicator */}
|
||||
{variable.value !== '' && getValidationStatus(variable) && (
|
||||
<div className='absolute top-2 right-2 z-10'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='cursor-help'>
|
||||
<AlertTriangle className='h-3 w-3 text-muted-foreground' />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='max-w-xs'>
|
||||
<p>{getValidationStatus(variable)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className='relative overflow-hidden'>
|
||||
<div
|
||||
className='relative min-h-[36px] rounded-md bg-background px-4 pt-2 pb-3 font-mono text-sm'
|
||||
className='relative min-h-[36px] w-full max-w-full px-3 py-2 font-normal text-sm'
|
||||
ref={(el) => {
|
||||
editorRefs.current[variable.id] = el
|
||||
}}
|
||||
style={{
|
||||
maxWidth: panelWidth ? `${panelWidth - 50}px` : '100%',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{variable.value === '' && (
|
||||
<div className='pointer-events-none absolute top-[8.5px] left-4 select-none text-muted-foreground/50'>
|
||||
{getPlaceholder(variable.type)}
|
||||
<div className='pointer-events-none absolute inset-0 flex select-none items-start justify-start px-3 py-2 font-[380] text-muted-foreground text-sm leading-normal'>
|
||||
<div style={{ lineHeight: '20px' }}>{getPlaceholder(variable.type)}</div>
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
@@ -375,7 +382,10 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
onBlur={() => handleEditorBlur(variable.id)}
|
||||
onFocus={() => handleEditorFocus(variable.id)}
|
||||
highlight={(code) =>
|
||||
highlight(
|
||||
// Only apply syntax highlighting for non-basic text types
|
||||
variable.type === 'plain' || variable.type === 'string'
|
||||
? code
|
||||
: highlight(
|
||||
code,
|
||||
languages[getEditorLanguage(variable.type)],
|
||||
getEditorLanguage(variable.type)
|
||||
@@ -384,50 +394,36 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '21px',
|
||||
lineHeight: '20px',
|
||||
width: '100%',
|
||||
wordWrap: 'break-word',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'break-word',
|
||||
minHeight: '20px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className='w-full focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full whitespace-pre-wrap break-words overflow-visible'
|
||||
className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground'
|
||||
/>
|
||||
|
||||
{/* Show validation indicator for any non-empty variable */}
|
||||
{variable.value !== '' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='group absolute top-[4px] right-[0px] cursor-help'>
|
||||
{getValidationStatus(variable) && (
|
||||
<div className='rounded-md border border-transparent p-1 transition-all duration-200 group-hover:border-muted/50 group-hover:bg-muted/80 group-hover:shadow-sm'>
|
||||
<AlertTriangle className='h-4 w-4 text-muted-foreground opacity-30 transition-opacity duration-200 group-hover:opacity-100' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='max-w-xs'>
|
||||
{getValidationStatus(variable) && <p>{getValidationStatus(variable)}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Variable Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='mt-2 w-full justify-start text-muted-foreground text-xs hover:text-foreground'
|
||||
onClick={handleAddVariable}
|
||||
className='mt-2 h-9 w-full rounded-lg border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 font-[380] text-muted-foreground text-sm shadow-xs transition-colors hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020] dark:hover:text-muted-foreground'
|
||||
variant='outline'
|
||||
>
|
||||
<Plus className='mr-1.5 h-3.5 w-3.5' />
|
||||
<Plus className='h-4 w-4' />
|
||||
Add variable
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Expand, PanelRight } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowDownToLine, CircleSlash, X } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
@@ -13,210 +13,199 @@ import { Console } from './components/console/console'
|
||||
import { Variables } from './components/variables/variables'
|
||||
|
||||
export function Panel() {
|
||||
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [chatMessage, setChatMessage] = useState<string>('')
|
||||
const [copilotMessage, setCopilotMessage] = useState<string>('')
|
||||
const [isChatModalOpen, setIsChatModalOpen] = useState(false)
|
||||
const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false)
|
||||
const copilotRef = useRef<{ clearMessages: () => void }>(null)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [resizeStartX, setResizeStartX] = useState(0)
|
||||
const [resizeStartWidth, setResizeStartWidth] = useState(0)
|
||||
|
||||
const isOpen = usePanelStore((state) => state.isOpen)
|
||||
const togglePanel = usePanelStore((state) => state.togglePanel)
|
||||
const activeTab = usePanelStore((state) => state.activeTab)
|
||||
const setActiveTab = usePanelStore((state) => state.setActiveTab)
|
||||
const panelWidth = usePanelStore((state) => state.panelWidth)
|
||||
const setPanelWidth = usePanelStore((state) => state.setPanelWidth)
|
||||
|
||||
const clearConsole = useConsoleStore((state) => state.clearConsole)
|
||||
const exportConsoleCSV = useConsoleStore((state) => state.exportConsoleCSV)
|
||||
const clearChat = useChatStore((state) => state.clearChat)
|
||||
const exportChatCSV = useChatStore((state) => state.exportChatCSV)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
const handleTabClick = (tab: 'chat' | 'console' | 'variables') => {
|
||||
setActiveTab(tab)
|
||||
if (!isOpen) {
|
||||
togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClosePanel = () => {
|
||||
togglePanel()
|
||||
}
|
||||
|
||||
// Resize functionality
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isOpen) return
|
||||
e.preventDefault()
|
||||
}
|
||||
setIsResizing(true)
|
||||
setResizeStartX(e.clientX)
|
||||
setResizeStartWidth(panelWidth)
|
||||
},
|
||||
[isOpen, panelWidth]
|
||||
)
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isResizing) return
|
||||
const deltaX = resizeStartX - e.clientX // Subtract because we're expanding left
|
||||
const newWidth = resizeStartWidth + deltaX
|
||||
setPanelWidth(newWidth)
|
||||
},
|
||||
[isResizing, resizeStartX, resizeStartWidth, setPanelWidth]
|
||||
)
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false)
|
||||
}, [])
|
||||
|
||||
// Add global mouse event listeners for resize
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
setWidth(Math.max(336, Math.min(newWidth, window.innerWidth * 0.8)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', handleResizeEnd)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', handleResizeEnd)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={togglePanel}
|
||||
className='fixed right-4 bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
>
|
||||
<PanelRight className='h-5 w-5' />
|
||||
<span className='sr-only'>Open Panel</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>Open Panel</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}, [isResizing, handleResize, handleResizeEnd])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='fixed top-16 right-0 z-10 flex h-[calc(100vh-4rem)] flex-col border-l bg-background'
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div
|
||||
className='absolute top-0 bottom-0 left-[-4px] z-50 w-4 cursor-ew-resize hover:bg-accent/50'
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
{/* Panel Header */}
|
||||
<div className='flex h-14 flex-none items-center justify-between border-b px-4'>
|
||||
<div className='flex gap-2'>
|
||||
{/* Tab Selector - Always visible */}
|
||||
<div className='fixed top-[76px] right-4 z-20 flex h-9 w-[308px] items-center gap-1 rounded-[14px] border bg-card px-[2.5px] py-1 shadow-xs'>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
onClick={() => handleTabClick('chat')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'chat' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('console')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'console'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
onClick={() => handleTabClick('console')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'console' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Console
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('variables')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'variables'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
onClick={() => handleTabClick('variables')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'variables' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Variables
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('copilot')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'copilot'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
{(activeTab === 'console' || activeTab === 'chat') /* || activeTab === 'copilot' */ && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'console') {
|
||||
clearConsole(activeWorkflowId)
|
||||
} else if (activeTab === 'chat') {
|
||||
clearChat(activeWorkflowId)
|
||||
}
|
||||
// else if (activeTab === 'copilot') {
|
||||
// copilotRef.current?.clearMessages()
|
||||
// }
|
||||
}}
|
||||
className='rounded-md px-3 py-1 text-muted-foreground text-sm transition-colors hover:bg-accent/50 hover:text-foreground'
|
||||
{/* Panel Content - Only visible when isOpen is true */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className='fixed top-[124px] right-4 bottom-4 z-10 flex flex-col rounded-[14px] border bg-card shadow-xs'
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
{activeTab === 'chat' ? (
|
||||
<Chat panelWidth={width} chatMessage={chatMessage} setChatMessage={setChatMessage} />
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={width} />
|
||||
) : (
|
||||
/* activeTab === 'copilot' ? (
|
||||
<Copilot
|
||||
ref={copilotRef}
|
||||
panelWidth={width}
|
||||
isFullscreen={isCopilotModalOpen}
|
||||
onFullscreenToggle={setIsCopilotModalOpen}
|
||||
fullscreenInput={copilotMessage}
|
||||
onFullscreenInputChange={setCopilotMessage}
|
||||
{/* Invisible resize handle */}
|
||||
<div
|
||||
className='-left-1 absolute top-0 bottom-0 w-2 cursor-col-resize'
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
) : */ <Variables panelWidth={width} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel Footer */}
|
||||
<div className='flex h-16 flex-none items-center justify-between border-t bg-background px-4'>
|
||||
{/* Header - Fixed width content */}
|
||||
<div className='flex items-center justify-between px-3 pt-3 pb-1'>
|
||||
<h2 className='font-[450] text-base text-card-foreground capitalize'>{activeTab}</h2>
|
||||
<div className='flex items-center gap-2'>
|
||||
{activeTab === 'console' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={togglePanel}
|
||||
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
onClick={() => activeWorkflowId && exportConsoleCSV(activeWorkflowId)}
|
||||
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{ color: 'var(--base-muted-foreground)' }}
|
||||
>
|
||||
<PanelRight className='h-5 w-5 rotate-180 transform' />
|
||||
<span className='sr-only'>Close Panel</span>
|
||||
<ArrowDownToLine className='h-4 w-4' strokeWidth={2} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>Close Panel</TooltipContent>
|
||||
<TooltipContent side='bottom'>Export console data</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
{activeTab === 'chat' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setIsChatModalOpen(true)}
|
||||
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
onClick={() => activeWorkflowId && exportChatCSV(activeWorkflowId)}
|
||||
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{ color: 'var(--base-muted-foreground)' }}
|
||||
>
|
||||
<Expand className='h-5 w-5' />
|
||||
<span className='sr-only'>Expand Chat</span>
|
||||
<ArrowDownToLine className='h-4 w-4' strokeWidth={2} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>Expand Chat</TooltipContent>
|
||||
<TooltipContent side='bottom'>Export chat data</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* activeTab === 'copilot' && (
|
||||
{(activeTab === 'console' || activeTab === 'chat') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setIsCopilotModalOpen(true)}
|
||||
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
onClick={() =>
|
||||
activeTab === 'console'
|
||||
? clearConsole(activeWorkflowId)
|
||||
: clearChat(activeWorkflowId)
|
||||
}
|
||||
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{ color: 'var(--base-muted-foreground)' }}
|
||||
>
|
||||
<Expand className='h-5 w-5' />
|
||||
<span className='sr-only'>Expand Copilot</span>
|
||||
<CircleSlash className='h-4 w-4' strokeWidth={2} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>Expand Copilot</TooltipContent>
|
||||
<TooltipContent side='bottom'>Clear {activeTab}</TooltipContent>
|
||||
</Tooltip>
|
||||
) */}
|
||||
)}
|
||||
<button
|
||||
onClick={handleClosePanel}
|
||||
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{ color: 'var(--base-muted-foreground)' }}
|
||||
>
|
||||
<X className='h-4 w-4' strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel Content Area - Resizable */}
|
||||
<div className='flex-1 overflow-hidden px-3'>
|
||||
{activeTab === 'chat' ? (
|
||||
<Chat
|
||||
panelWidth={panelWidth}
|
||||
chatMessage={chatMessage}
|
||||
setChatMessage={setChatMessage}
|
||||
/>
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={panelWidth} />
|
||||
) : (
|
||||
<Variables />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Chat Modal */}
|
||||
<ChatModal
|
||||
open={isChatModalOpen}
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { Bell, Bug, ChevronDown, Copy, History, Layers, Play, Rocket, Trash2 } from 'lucide-react'
|
||||
import { Bug, Copy, Layers, Play, Rocket, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const SkeletonControlBar = () => {
|
||||
return (
|
||||
<div className='flex h-16 w-full items-center justify-between border-b bg-background'>
|
||||
{/* Left Section - Workflow Name Skeleton */}
|
||||
<div className='flex flex-col gap-[2px] pl-4'>
|
||||
{/* Workflow name skeleton */}
|
||||
<Skeleton className='h-[20px] w-32' />
|
||||
{/* "Saved X time ago" skeleton */}
|
||||
<Skeleton className='h-3 w-24' />
|
||||
</div>
|
||||
|
||||
{/* Middle Section */}
|
||||
<div className='flex-1' />
|
||||
|
||||
{/* Right Section - Action Buttons with Real Icons */}
|
||||
<div className='flex items-center gap-1 pr-4'>
|
||||
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
|
||||
{/* Delete Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))]'
|
||||
disabled
|
||||
>
|
||||
<Trash2 className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* History Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<History className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Notifications Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Bell className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Duplicate Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
|
||||
disabled
|
||||
>
|
||||
<Copy className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Auto Layout Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
|
||||
disabled
|
||||
>
|
||||
<Layers className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Debug Mode Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
|
||||
disabled
|
||||
>
|
||||
<Bug className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Deploy Button */}
|
||||
<Button variant='ghost' size='icon' disabled className='opacity-60'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
|
||||
disabled
|
||||
>
|
||||
<Rocket className='h-5 w-5' />
|
||||
</Button>
|
||||
|
||||
{/* Run Button with Dropdown */}
|
||||
<div className='ml-1 flex'>
|
||||
{/* Main Run Button */}
|
||||
{/* Run Button */}
|
||||
<Button
|
||||
className='h-12 cursor-not-allowed gap-2 rounded-[11px] bg-[#701FFC] px-4 py-2 font-medium text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none'
|
||||
disabled
|
||||
className='h-10 gap-2 rounded-r-none border-r border-r-[#6420cc] bg-[#701FFC] px-4 py-2 font-medium text-white opacity-60'
|
||||
>
|
||||
<Play className='h-3.5 w-3.5 fill-current stroke-current' />
|
||||
Run
|
||||
</Button>
|
||||
|
||||
{/* Dropdown Trigger */}
|
||||
<Button
|
||||
disabled
|
||||
className='h-10 rounded-l-none bg-[#701FFC] px-2 font-medium text-white opacity-60'
|
||||
>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -163,11 +146,8 @@ export function SkeletonLoading({
|
||||
isSidebarCollapsed,
|
||||
children,
|
||||
}: SkeletonLoadingProps) {
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
|
||||
return (
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden'>
|
||||
<div className={`transition-all duration-200 ${isSidebarCollapsed ? 'ml-14' : 'ml-60'}`}>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden pl-64'>
|
||||
{/* Skeleton Control Bar */}
|
||||
<div
|
||||
className={`transition-opacity duration-500 ${showSkeleton ? 'opacity-100' : 'pointer-events-none absolute opacity-0'}`}
|
||||
@@ -183,7 +163,6 @@ export function SkeletonLoading({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real content will be rendered by children - sidebar will show its own loading state */}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface ToolbarTabsProps {
|
||||
activeTab: 'blocks' | 'tools'
|
||||
onTabChange: (tab: 'blocks' | 'tools') => void
|
||||
}
|
||||
|
||||
export function ToolbarTabs({ activeTab, onTabChange }: ToolbarTabsProps) {
|
||||
const blocksRef = useRef<HTMLButtonElement>(null)
|
||||
const toolsRef = useRef<HTMLButtonElement>(null)
|
||||
const [underlineStyle, setUnderlineStyle] = useState({
|
||||
width: 0,
|
||||
transform: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const activeRef = activeTab === 'blocks' ? blocksRef : toolsRef
|
||||
if (activeRef.current) {
|
||||
const rect = activeRef.current.getBoundingClientRect()
|
||||
const parentRect = activeRef.current.parentElement?.getBoundingClientRect()
|
||||
const offsetLeft = parentRect ? rect.left - parentRect.left : 0
|
||||
|
||||
setUnderlineStyle({
|
||||
width: rect.width,
|
||||
transform: `translateX(${offsetLeft}px)`,
|
||||
})
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
return (
|
||||
<div className='relative pt-5'>
|
||||
<div className='flex gap-8 px-6'>
|
||||
<button
|
||||
ref={blocksRef}
|
||||
onClick={() => onTabChange('blocks')}
|
||||
className={`font-medium text-sm transition-colors hover:text-foreground ${
|
||||
activeTab === 'blocks' ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Blocks
|
||||
</button>
|
||||
<button
|
||||
ref={toolsRef}
|
||||
onClick={() => onTabChange('tools')}
|
||||
className={`font-medium text-sm transition-colors hover:text-foreground ${
|
||||
activeTab === 'tools' ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Tools
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-2'>
|
||||
<div className='absolute bottom-0 h-[1px] w-full border-b' />
|
||||
<div
|
||||
className='absolute bottom-0 h-[1.5px] bg-foreground transition-transform duration-200'
|
||||
style={{
|
||||
width: `${underlineStyle.width}px`,
|
||||
transform: underlineStyle.transform,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { PanelLeftClose, PanelRight, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { getAllBlocks, getBlocksByCategory } from '@/blocks'
|
||||
import type { BlockCategory } from '@/blocks/types'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { ToolbarBlock } from './components/toolbar-block/toolbar-block'
|
||||
import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block'
|
||||
import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block'
|
||||
import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs'
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void
|
||||
className: string
|
||||
children: React.ReactNode
|
||||
tooltipContent: string
|
||||
tooltipSide?: 'left' | 'right' | 'top' | 'bottom'
|
||||
}
|
||||
|
||||
const ToolbarButton = React.memo<ToolbarButtonProps>(
|
||||
({ onClick, className, children, tooltipContent, tooltipSide = 'right' }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onClick} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = 'ToolbarButton'
|
||||
|
||||
export const Toolbar = React.memo(() => {
|
||||
const params = useParams()
|
||||
const workflowId = params?.id as string
|
||||
|
||||
// Get the workspace ID from URL params
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const currentWorkflow = useMemo(
|
||||
() => (workflowId ? workflows[workflowId] : null),
|
||||
[workflowId, workflows]
|
||||
)
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<BlockCategory>('blocks')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
|
||||
// In hover mode, act as if sidebar is always collapsed for layout purposes
|
||||
const isSidebarCollapsed = useMemo(
|
||||
() => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'),
|
||||
[mode, isExpanded]
|
||||
)
|
||||
|
||||
// State to track if toolbar is open - independent of sidebar state
|
||||
const [isToolbarOpen, setIsToolbarOpen] = useState(true)
|
||||
|
||||
const blocks = useMemo(() => {
|
||||
const filteredBlocks = !searchQuery.trim() ? getBlocksByCategory(activeTab) : getAllBlocks()
|
||||
|
||||
return filteredBlocks.filter((block) => {
|
||||
if (block.type === 'starter' || block.hideFromToolbar) return false
|
||||
|
||||
return (
|
||||
!searchQuery.trim() ||
|
||||
block.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
block.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
})
|
||||
}, [searchQuery, activeTab])
|
||||
|
||||
const handleOpenToolbar = useCallback(() => {
|
||||
setIsToolbarOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseToolbar = useCallback(() => {
|
||||
setIsToolbarOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleTabChange = useCallback((tab: BlockCategory) => {
|
||||
setActiveTab(tab)
|
||||
}, [])
|
||||
|
||||
// Show toolbar button when it's closed, regardless of sidebar state
|
||||
if (!isToolbarOpen) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
onClick={handleOpenToolbar}
|
||||
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-20' : 'left-64'} bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg border bg-background text-muted-foreground hover:bg-accent hover:text-foreground`}
|
||||
tooltipContent='Open Toolbar'
|
||||
tooltipSide='right'
|
||||
>
|
||||
<PanelRight className='h-5 w-5' />
|
||||
<span className='sr-only'>Open Toolbar</span>
|
||||
</ToolbarButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'} top-16 z-10 h-[calc(100vh-4rem)] w-60 border-r bg-background sm:block`}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='sticky top-0 z-20 bg-background px-4 pt-4 pb-1'>
|
||||
<div className='relative'>
|
||||
<Search className='-translate-y-[50%] absolute top-[50%] left-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
placeholder='Search...'
|
||||
className='rounded-md pl-9'
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!searchQuery && (
|
||||
<div className='sticky top-[72px] z-20 bg-background'>
|
||||
<ToolbarTabs activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className='h-[calc(100%-4rem)]'>
|
||||
<div className='p-4 pb-20'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{blocks.map((block) => (
|
||||
<ToolbarBlock key={block.type} config={block} disabled={!userPermissions.canEdit} />
|
||||
))}
|
||||
{((activeTab === 'blocks' && !searchQuery) ||
|
||||
(searchQuery && 'loop'.includes(searchQuery.toLowerCase()))) && (
|
||||
<LoopToolbarItem disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
{((activeTab === 'blocks' && !searchQuery) ||
|
||||
(searchQuery && 'parallel'.includes(searchQuery.toLowerCase()))) && (
|
||||
<ParallelToolbarItem disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className='absolute right-0 bottom-0 left-0 h-16 border-t bg-background'>
|
||||
<ToolbarButton
|
||||
onClick={handleCloseToolbar}
|
||||
className='absolute right-4 bottom-[18px] flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
tooltipContent='Close Toolbar'
|
||||
tooltipSide='left'
|
||||
>
|
||||
<PanelLeftClose className='h-5 w-5' />
|
||||
<span className='sr-only'>Close Toolbar</span>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
@@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useSubBlockValue } from '../hooks/use-sub-block-value'
|
||||
|
||||
interface DateInputProps {
|
||||
@@ -31,7 +30,6 @@ export function DateInput({
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
const date = value ? new Date(value) : undefined
|
||||
|
||||
const isPastDate = React.useMemo(() => {
|
||||
@@ -47,10 +45,6 @@ export function DateInput({
|
||||
if (selectedDate) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (selectedDate < today) {
|
||||
addNotification('error', 'Cannot start at a date in the past', blockId)
|
||||
}
|
||||
}
|
||||
setStoreValue(selectedDate?.toISOString() || '')
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { useSubBlockValue } from '../hooks/use-sub-block-value'
|
||||
|
||||
const logger = createLogger('FileUpload')
|
||||
|
||||
interface FileUploadProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
@@ -55,7 +57,6 @@ export function FileUpload({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Stores
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
@@ -110,8 +111,7 @@ export function FileUpload({
|
||||
const file = files[i]
|
||||
// Check if adding this file would exceed the total limit
|
||||
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
|
||||
addNotification(
|
||||
'error',
|
||||
logger.error(
|
||||
`Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`,
|
||||
activeWorkflowId
|
||||
)
|
||||
@@ -248,14 +248,12 @@ export function FileUpload({
|
||||
if (uploadedFiles.length > 0) {
|
||||
const uploadMethod = useDirectUpload ? 'direct' : 'server'
|
||||
if (uploadedFiles.length === 1) {
|
||||
addNotification(
|
||||
'console',
|
||||
logger.info(
|
||||
`${uploadedFiles[0].name} was uploaded successfully (${uploadMethod} upload)`,
|
||||
activeWorkflowId
|
||||
)
|
||||
} else {
|
||||
addNotification(
|
||||
'console',
|
||||
logger.info(
|
||||
`Uploaded ${uploadedFiles.length} files successfully: ${uploadedFiles.map((f) => f.name).join(', ')} (${uploadMethod} upload)`,
|
||||
activeWorkflowId
|
||||
)
|
||||
@@ -265,10 +263,9 @@ export function FileUpload({
|
||||
// Send consolidated error notification if any
|
||||
if (uploadErrors.length > 0) {
|
||||
if (uploadErrors.length === 1) {
|
||||
addNotification('error', uploadErrors[0], activeWorkflowId)
|
||||
logger.error(uploadErrors[0], activeWorkflowId)
|
||||
} else {
|
||||
addNotification(
|
||||
'error',
|
||||
logger.error(
|
||||
`Failed to upload ${uploadErrors.length} files: ${uploadErrors.join('; ')}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
@@ -303,8 +300,7 @@ export function FileUpload({
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification(
|
||||
'error',
|
||||
logger.error(
|
||||
error instanceof Error ? error.message : 'Failed to upload file(s)',
|
||||
activeWorkflowId
|
||||
)
|
||||
@@ -362,8 +358,7 @@ export function FileUpload({
|
||||
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
} catch (error) {
|
||||
addNotification(
|
||||
'error',
|
||||
logger.error(
|
||||
error instanceof Error ? error.message : 'Failed to delete file from server',
|
||||
activeWorkflowId
|
||||
)
|
||||
@@ -439,14 +434,9 @@ export function FileUpload({
|
||||
// Show error notification if any deletions failed
|
||||
if (deletionResults.failures.length > 0) {
|
||||
if (deletionResults.failures.length === 1) {
|
||||
addNotification(
|
||||
'error',
|
||||
`Failed to delete file: ${deletionResults.failures[0]}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId)
|
||||
} else {
|
||||
addNotification(
|
||||
'error',
|
||||
logger.error(
|
||||
`Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
@@ -44,7 +43,6 @@ export function useCodeGeneration({
|
||||
const [promptInputValue, setPromptInputValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
// State for conversation history
|
||||
const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([])
|
||||
@@ -58,7 +56,6 @@ export function useCodeGeneration({
|
||||
if (!prompt) {
|
||||
const errorMessage = 'Prompt cannot be empty.'
|
||||
setError(errorMessage)
|
||||
addNotification('error', errorMessage, null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,7 +89,6 @@ export function useCodeGeneration({
|
||||
|
||||
logger.info('Code generation successful', { generationType })
|
||||
onGeneratedContent(result.generatedContent)
|
||||
addNotification('info', 'Content generated successfully!', null)
|
||||
setIsPromptOpen(false)
|
||||
setIsPromptVisible(false)
|
||||
|
||||
@@ -110,7 +106,6 @@ export function useCodeGeneration({
|
||||
const errorMessage = err.message || 'An unknown error occurred during generation.'
|
||||
logger.error('Code generation failed', { error: errorMessage })
|
||||
setError(errorMessage)
|
||||
addNotification('error', `Generation failed: ${errorMessage}`, null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -121,7 +116,6 @@ export function useCodeGeneration({
|
||||
if (!prompt) {
|
||||
const errorMessage = 'Prompt cannot be empty.'
|
||||
setError(errorMessage)
|
||||
addNotification('error', errorMessage, null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -223,7 +217,6 @@ export function useCodeGeneration({
|
||||
if (onGenerationComplete) {
|
||||
onGenerationComplete(currentPrompt, fullContent)
|
||||
}
|
||||
addNotification('info', 'Content generated successfully!', null)
|
||||
break
|
||||
}
|
||||
} catch (jsonError: any) {
|
||||
@@ -251,7 +244,6 @@ export function useCodeGeneration({
|
||||
const errorMessage = err.message || 'An unknown error occurred during streaming.'
|
||||
logger.error('Streaming code generation failed', { error: errorMessage })
|
||||
setError(errorMessage)
|
||||
addNotification('error', `Generation failed: ${errorMessage}`, null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsStreaming(false)
|
||||
|
||||
@@ -9,9 +9,7 @@ import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/t
|
||||
import { Serializer } from '@/serializer'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
@@ -37,18 +35,16 @@ interface ExecutorOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// Interface for stream error handling
|
||||
interface StreamError extends Error {
|
||||
name: string
|
||||
message: string
|
||||
// Debug state validation result
|
||||
interface DebugValidationResult {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useWorkflowExecution() {
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { toggleConsole } = useConsoleStore()
|
||||
const { togglePanel, setActiveTab, activeTab } = usePanelStore()
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { isDebugModeEnabled } = useGeneralStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
@@ -67,6 +63,123 @@ export function useWorkflowExecution() {
|
||||
} = useExecutionStore()
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
|
||||
|
||||
/**
|
||||
* Validates debug state before performing debug operations
|
||||
*/
|
||||
const validateDebugState = useCallback((): DebugValidationResult => {
|
||||
if (!executor || !debugContext || pendingBlocks.length === 0) {
|
||||
const missing = []
|
||||
if (!executor) missing.push('executor')
|
||||
if (!debugContext) missing.push('debugContext')
|
||||
if (pendingBlocks.length === 0) missing.push('pendingBlocks')
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Cannot perform debug operation - missing: ${missing.join(', ')}. Try restarting debug mode.`,
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}, [executor, debugContext, pendingBlocks])
|
||||
|
||||
/**
|
||||
* Resets all debug-related state
|
||||
*/
|
||||
const resetDebugState = useCallback(() => {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
|
||||
// Reset debug mode setting if it was enabled
|
||||
if (isDebugModeEnabled) {
|
||||
useGeneralStore.getState().toggleDebugMode()
|
||||
}
|
||||
}, [
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setDebugContext,
|
||||
setExecutor,
|
||||
setPendingBlocks,
|
||||
setActiveBlocks,
|
||||
isDebugModeEnabled,
|
||||
])
|
||||
|
||||
/**
|
||||
* Checks if debug session is complete based on execution result
|
||||
*/
|
||||
const isDebugSessionComplete = useCallback((result: ExecutionResult): boolean => {
|
||||
return (
|
||||
!result.metadata?.isDebugSession ||
|
||||
!result.metadata.pendingBlocks ||
|
||||
result.metadata.pendingBlocks.length === 0
|
||||
)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles debug session completion
|
||||
*/
|
||||
const handleDebugSessionComplete = useCallback(
|
||||
async (result: ExecutionResult) => {
|
||||
logger.info('Debug session complete')
|
||||
setExecutionResult(result)
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), result)
|
||||
|
||||
// Reset debug state
|
||||
resetDebugState()
|
||||
},
|
||||
[activeWorkflowId, resetDebugState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles debug session continuation
|
||||
*/
|
||||
const handleDebugSessionContinuation = useCallback(
|
||||
(result: ExecutionResult) => {
|
||||
logger.info('Debug step completed, next blocks pending', {
|
||||
nextPendingBlocks: result.metadata?.pendingBlocks?.length || 0,
|
||||
})
|
||||
|
||||
// Update debug context and pending blocks
|
||||
if (result.metadata?.context) {
|
||||
setDebugContext(result.metadata.context)
|
||||
}
|
||||
if (result.metadata?.pendingBlocks) {
|
||||
setPendingBlocks(result.metadata.pendingBlocks)
|
||||
}
|
||||
},
|
||||
[setDebugContext, setPendingBlocks]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles debug execution errors
|
||||
*/
|
||||
const handleDebugExecutionError = useCallback(
|
||||
async (error: any, operation: string) => {
|
||||
logger.error(`Debug ${operation} Error:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
error: errorMessage,
|
||||
logs: debugContext?.blockLogs || [],
|
||||
}
|
||||
|
||||
setExecutionResult(errorResult)
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), errorResult)
|
||||
|
||||
// Reset debug state
|
||||
resetDebugState()
|
||||
},
|
||||
[debugContext, activeWorkflowId, resetDebugState]
|
||||
)
|
||||
|
||||
const persistLogs = async (
|
||||
executionId: string,
|
||||
result: ExecutionResult,
|
||||
@@ -129,35 +242,21 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
const handleRunWorkflow = useCallback(
|
||||
async (workflowInput?: any) => {
|
||||
async (workflowInput?: any, enableDebug = false) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
// Reset execution result and set execution state
|
||||
setExecutionResult(null)
|
||||
setIsExecuting(true)
|
||||
|
||||
// Set debug mode if it's enabled in settings
|
||||
if (isDebugModeEnabled) {
|
||||
// Set debug mode only if explicitly requested
|
||||
if (enableDebug) {
|
||||
setIsDebugging(true)
|
||||
}
|
||||
|
||||
// Check if panel is open and open it if not
|
||||
const isPanelOpen = usePanelStore.getState().isOpen
|
||||
if (!isPanelOpen) {
|
||||
togglePanel()
|
||||
}
|
||||
|
||||
// Set active tab to console
|
||||
if (activeTab !== 'console' && activeTab !== 'chat') {
|
||||
setActiveTab('console')
|
||||
}
|
||||
|
||||
// Determine if this is a chat execution
|
||||
const isChatExecution =
|
||||
activeTab === 'chat' &&
|
||||
workflowInput &&
|
||||
typeof workflowInput === 'object' &&
|
||||
'input' in workflowInput
|
||||
workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput
|
||||
|
||||
// For chat executions, we'll use a streaming approach
|
||||
if (isChatExecution) {
|
||||
@@ -246,13 +345,6 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
controller.error(error)
|
||||
if (activeWorkflowId) {
|
||||
addNotification(
|
||||
'error',
|
||||
`Workflow execution failed: ${error.message}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
controller.close()
|
||||
setIsExecuting(false)
|
||||
@@ -288,15 +380,6 @@ export function useWorkflowExecution() {
|
||||
;(result.metadata as any).source = 'chat'
|
||||
}
|
||||
|
||||
if (activeWorkflowId) {
|
||||
addNotification(
|
||||
result.success ? 'console' : 'error',
|
||||
result.success
|
||||
? 'Workflow completed successfully'
|
||||
: `Workflow execution failed: ${result.error || 'Unknown error'}`,
|
||||
activeWorkflowId
|
||||
)
|
||||
}
|
||||
persistLogs(executionId, result).catch((err) => {
|
||||
logger.error('Error persisting logs:', { error: err })
|
||||
})
|
||||
@@ -316,11 +399,7 @@ export function useWorkflowExecution() {
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
addNotification,
|
||||
toggleConsole,
|
||||
togglePanel,
|
||||
setActiveTab,
|
||||
activeTab,
|
||||
getAllVariables,
|
||||
getVariablesByWorkflowId,
|
||||
isDebugModeEnabled,
|
||||
@@ -379,10 +458,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Determine if this is a chat execution
|
||||
const isChatExecution =
|
||||
activeTab === 'chat' &&
|
||||
workflowInput &&
|
||||
typeof workflowInput === 'object' &&
|
||||
'input' in workflowInput
|
||||
workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput
|
||||
|
||||
// If this is a chat execution, get the selected outputs
|
||||
let selectedOutputIds: string[] | undefined
|
||||
@@ -476,13 +552,6 @@ export function useWorkflowExecution() {
|
||||
notificationMessage += `: ${errorMessage}`
|
||||
}
|
||||
|
||||
try {
|
||||
addNotification('error', notificationMessage, activeWorkflowId || '')
|
||||
} catch (notificationError) {
|
||||
logger.error('Error showing error notification:', notificationError)
|
||||
console.error('Workflow execution failed:', errorMessage)
|
||||
}
|
||||
|
||||
return errorResult
|
||||
}
|
||||
|
||||
@@ -490,186 +559,73 @@ export function useWorkflowExecution() {
|
||||
* Handles stepping through workflow execution in debug mode
|
||||
*/
|
||||
const handleStepDebug = useCallback(async () => {
|
||||
// Log debug information
|
||||
logger.info('Step Debug requested', {
|
||||
hasExecutor: !!executor,
|
||||
hasContext: !!debugContext,
|
||||
pendingBlockCount: pendingBlocks.length,
|
||||
})
|
||||
|
||||
if (!executor || !debugContext || pendingBlocks.length === 0) {
|
||||
logger.error('Cannot step debug - missing required state', {
|
||||
executor: !!executor,
|
||||
debugContext: !!debugContext,
|
||||
pendingBlocks: pendingBlocks.length,
|
||||
})
|
||||
|
||||
// Show error notification
|
||||
addNotification(
|
||||
'error',
|
||||
'Cannot step through debugging - missing execution state. Try restarting debug mode.',
|
||||
activeWorkflowId || ''
|
||||
)
|
||||
|
||||
// Reset debug state
|
||||
setIsDebugging(false)
|
||||
setIsExecuting(false)
|
||||
setActiveBlocks(new Set())
|
||||
// Validate debug state
|
||||
const validation = validateDebugState()
|
||||
if (!validation.isValid) {
|
||||
resetDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Executing debug step with blocks:', pendingBlocks)
|
||||
|
||||
// Execute the next step with the pending blocks
|
||||
const result = await executor.continueExecution(pendingBlocks, debugContext)
|
||||
|
||||
const result = await executor!.continueExecution(pendingBlocks, debugContext!)
|
||||
console.log('Debug step execution result:', result)
|
||||
|
||||
// Save the new context in the store
|
||||
if (result.metadata?.context) {
|
||||
setDebugContext(result.metadata.context)
|
||||
}
|
||||
|
||||
// Check if the debug session is complete
|
||||
if (
|
||||
!result.metadata?.isDebugSession ||
|
||||
!result.metadata.pendingBlocks ||
|
||||
result.metadata.pendingBlocks.length === 0
|
||||
) {
|
||||
logger.info('Debug session complete')
|
||||
// Debug session complete
|
||||
setExecutionResult(result)
|
||||
|
||||
// Show completion notification
|
||||
addNotification(
|
||||
result.success ? 'console' : 'error',
|
||||
result.success
|
||||
? 'Workflow completed successfully'
|
||||
: `Workflow execution failed: ${result.error}`,
|
||||
activeWorkflowId || ''
|
||||
)
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), result)
|
||||
|
||||
// Reset debug state
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
if (isDebugSessionComplete(result)) {
|
||||
await handleDebugSessionComplete(result)
|
||||
} else {
|
||||
// Debug session continues - update UI with new pending blocks
|
||||
logger.info('Debug step completed, next blocks pending', {
|
||||
nextPendingBlocks: result.metadata.pendingBlocks.length,
|
||||
})
|
||||
|
||||
// This is critical - ensure we update the pendingBlocks in the store
|
||||
setPendingBlocks(result.metadata.pendingBlocks)
|
||||
handleDebugSessionContinuation(result)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Debug Step Error:', error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Create error result
|
||||
const errorResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
error: errorMessage,
|
||||
logs: debugContext.blockLogs,
|
||||
}
|
||||
|
||||
setExecutionResult(errorResult)
|
||||
|
||||
// Safely show error notification
|
||||
try {
|
||||
addNotification('error', `Debug step failed: ${errorMessage}`, activeWorkflowId || '')
|
||||
} catch (notificationError) {
|
||||
logger.error('Error showing step error notification:', notificationError)
|
||||
console.error('Debug step failed:', errorMessage)
|
||||
}
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), errorResult)
|
||||
|
||||
// Reset debug state
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
await handleDebugExecutionError(error, 'step')
|
||||
}
|
||||
}, [
|
||||
executor,
|
||||
debugContext,
|
||||
pendingBlocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setPendingBlocks,
|
||||
setDebugContext,
|
||||
setExecutor,
|
||||
setActiveBlocks,
|
||||
validateDebugState,
|
||||
resetDebugState,
|
||||
isDebugSessionComplete,
|
||||
handleDebugSessionComplete,
|
||||
handleDebugSessionContinuation,
|
||||
handleDebugExecutionError,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles resuming execution in debug mode until completion
|
||||
*/
|
||||
const handleResumeDebug = useCallback(async () => {
|
||||
// Log debug information
|
||||
logger.info('Resume Debug requested', {
|
||||
hasExecutor: !!executor,
|
||||
hasContext: !!debugContext,
|
||||
pendingBlockCount: pendingBlocks.length,
|
||||
})
|
||||
|
||||
if (!executor || !debugContext || pendingBlocks.length === 0) {
|
||||
logger.error('Cannot resume debug - missing required state', {
|
||||
executor: !!executor,
|
||||
debugContext: !!debugContext,
|
||||
pendingBlocks: pendingBlocks.length,
|
||||
})
|
||||
|
||||
// Show error notification
|
||||
addNotification(
|
||||
'error',
|
||||
'Cannot resume debugging - missing execution state. Try restarting debug mode.',
|
||||
activeWorkflowId || ''
|
||||
)
|
||||
|
||||
// Reset debug state
|
||||
setIsDebugging(false)
|
||||
setIsExecuting(false)
|
||||
setActiveBlocks(new Set())
|
||||
// Validate debug state
|
||||
const validation = validateDebugState()
|
||||
if (!validation.isValid) {
|
||||
resetDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Show a notification that we're resuming execution
|
||||
try {
|
||||
addNotification(
|
||||
'info',
|
||||
'Resuming workflow execution until completion',
|
||||
activeWorkflowId || ''
|
||||
)
|
||||
} catch (notificationError) {
|
||||
logger.error('Error showing resume notification:', notificationError)
|
||||
console.info('Resuming workflow execution until completion')
|
||||
}
|
||||
logger.info('Resuming workflow execution until completion')
|
||||
|
||||
let currentResult: ExecutionResult = {
|
||||
success: true,
|
||||
output: {},
|
||||
logs: debugContext.blockLogs,
|
||||
logs: debugContext!.blockLogs,
|
||||
}
|
||||
|
||||
// Create copies to avoid mutation issues
|
||||
let currentContext = { ...debugContext }
|
||||
let currentContext = { ...debugContext! }
|
||||
let currentPendingBlocks = [...pendingBlocks]
|
||||
|
||||
console.log('Starting resume execution with blocks:', currentPendingBlocks)
|
||||
@@ -683,7 +639,7 @@ export function useWorkflowExecution() {
|
||||
`Resume iteration ${iterationCount + 1}, executing ${currentPendingBlocks.length} blocks`
|
||||
)
|
||||
|
||||
currentResult = await executor.continueExecution(currentPendingBlocks, currentContext)
|
||||
currentResult = await executor!.continueExecution(currentPendingBlocks, currentContext)
|
||||
|
||||
logger.info('Resume iteration result:', {
|
||||
success: currentResult.success,
|
||||
@@ -696,7 +652,7 @@ export function useWorkflowExecution() {
|
||||
currentContext = currentResult.metadata.context
|
||||
} else {
|
||||
logger.info('No context in result, ending resume')
|
||||
break // No context means we're done
|
||||
break
|
||||
}
|
||||
|
||||
// Update pending blocks for next iteration
|
||||
@@ -704,7 +660,7 @@ export function useWorkflowExecution() {
|
||||
currentPendingBlocks = currentResult.metadata.pendingBlocks
|
||||
} else {
|
||||
logger.info('No pending blocks in result, ending resume')
|
||||
break // No pending blocks means we're done
|
||||
break
|
||||
}
|
||||
|
||||
// If we don't have a debug session anymore, we're done
|
||||
@@ -725,99 +681,29 @@ export function useWorkflowExecution() {
|
||||
success: currentResult.success,
|
||||
})
|
||||
|
||||
// Final result is the last step's result
|
||||
setExecutionResult(currentResult)
|
||||
|
||||
// Show completion notification
|
||||
try {
|
||||
addNotification(
|
||||
currentResult.success ? 'console' : 'error',
|
||||
currentResult.success
|
||||
? 'Workflow completed successfully'
|
||||
: `Workflow execution failed: ${currentResult.error}`,
|
||||
activeWorkflowId || ''
|
||||
)
|
||||
} catch (notificationError) {
|
||||
logger.error('Error showing completion notification:', notificationError)
|
||||
console.info('Workflow execution completed')
|
||||
}
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), currentResult)
|
||||
|
||||
// Reset debug state
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
// Handle completion
|
||||
await handleDebugSessionComplete(currentResult)
|
||||
} catch (error: any) {
|
||||
logger.error('Debug Resume Error:', error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Create error result
|
||||
const errorResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
error: errorMessage,
|
||||
logs: debugContext.blockLogs,
|
||||
}
|
||||
|
||||
setExecutionResult(errorResult)
|
||||
|
||||
// Safely show error notification
|
||||
try {
|
||||
addNotification('error', `Resume execution failed: ${errorMessage}`, activeWorkflowId || '')
|
||||
} catch (notificationError) {
|
||||
logger.error('Error showing resume error notification:', notificationError)
|
||||
console.error('Resume execution failed:', errorMessage)
|
||||
}
|
||||
|
||||
// Persist logs
|
||||
await persistLogs(uuidv4(), errorResult)
|
||||
|
||||
// Reset debug state
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
await handleDebugExecutionError(error, 'resume')
|
||||
}
|
||||
}, [
|
||||
executor,
|
||||
debugContext,
|
||||
pendingBlocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setPendingBlocks,
|
||||
setDebugContext,
|
||||
setExecutor,
|
||||
setActiveBlocks,
|
||||
validateDebugState,
|
||||
resetDebugState,
|
||||
handleDebugSessionComplete,
|
||||
handleDebugExecutionError,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles cancelling the current debugging session
|
||||
*/
|
||||
const handleCancelDebug = useCallback(() => {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
}, [
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setDebugContext,
|
||||
setExecutor,
|
||||
setPendingBlocks,
|
||||
setActiveBlocks,
|
||||
])
|
||||
logger.info('Debug session cancelled')
|
||||
resetDebugState()
|
||||
}, [resetDebugState])
|
||||
|
||||
return {
|
||||
isExecuting,
|
||||
|
||||
@@ -15,20 +15,15 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
|
||||
import { NotificationList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications'
|
||||
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
|
||||
import { SkeletonLoading } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading'
|
||||
import { Toolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { WorkflowBlock } from './components/workflow-block/workflow-block'
|
||||
@@ -70,13 +65,6 @@ interface BlockData {
|
||||
const WorkflowContent = React.memo(() => {
|
||||
// State
|
||||
const [isWorkflowReady, setIsWorkflowReady] = useState(false)
|
||||
const { mode, isExpanded } = useSidebarStore()
|
||||
|
||||
// In hover mode, act as if sidebar is always collapsed for layout purposes
|
||||
const isSidebarCollapsed = useMemo(
|
||||
() => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'),
|
||||
[mode, isExpanded]
|
||||
)
|
||||
|
||||
// State for tracking node dragging
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
@@ -122,7 +110,6 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeSetSubblockValue,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
const { markAllAsRead } = useNotificationStore()
|
||||
const { resetLoaded: resetVariablesLoaded } = useVariablesStore()
|
||||
|
||||
// Execution and debug mode state
|
||||
@@ -265,7 +252,6 @@ const WorkflowContent = React.memo(() => {
|
||||
...orientationConfig,
|
||||
alignByLayer: true,
|
||||
animationDuration: 500, // Smooth 500ms animation
|
||||
isSidebarCollapsed,
|
||||
handleOrientation: detectedOrientation, // Explicitly set the detected orientation
|
||||
onComplete: (finalPositions) => {
|
||||
// Emit collaborative updates for final positions after animation completes
|
||||
@@ -291,7 +277,6 @@ const WorkflowContent = React.memo(() => {
|
||||
storeUpdateBlockPosition,
|
||||
collaborativeUpdateBlockPosition,
|
||||
fitView,
|
||||
isSidebarCollapsed,
|
||||
resizeLoopNodesWrapper,
|
||||
getOrientationConfig,
|
||||
])
|
||||
@@ -904,8 +889,6 @@ const WorkflowContent = React.memo(() => {
|
||||
// Don't reset variables cache if we're not actually switching workflows
|
||||
setActiveWorkflow(currentId)
|
||||
}
|
||||
|
||||
markAllAsRead(currentId)
|
||||
}
|
||||
|
||||
validateAndNavigate()
|
||||
@@ -916,7 +899,6 @@ const WorkflowContent = React.memo(() => {
|
||||
setActiveWorkflow,
|
||||
createWorkflow,
|
||||
router,
|
||||
markAllAsRead,
|
||||
resetVariablesLoaded,
|
||||
])
|
||||
|
||||
@@ -1504,19 +1486,18 @@ const WorkflowContent = React.memo(() => {
|
||||
if (showSkeletonUI) {
|
||||
return (
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden'>
|
||||
<SkeletonLoading showSkeleton={true} isSidebarCollapsed={isSidebarCollapsed}>
|
||||
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
|
||||
</SkeletonLoading>
|
||||
<Toolbar />
|
||||
<div
|
||||
className={`${isSidebarCollapsed ? 'pl-14' : 'pl-60'} relative h-full w-full flex-1 transition-all duration-200`}
|
||||
>
|
||||
<div className='relative h-full w-full flex-1 transition-all duration-200'>
|
||||
<div className='fixed top-0 right-0 z-10'>
|
||||
<Panel />
|
||||
<NotificationList />
|
||||
</div>
|
||||
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
|
||||
<div className='workflow-container h-full'>
|
||||
<Background />
|
||||
<Background
|
||||
color='hsl(var(--workflow-dots))'
|
||||
size={4}
|
||||
gap={40}
|
||||
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1525,18 +1506,14 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
return (
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden'>
|
||||
<div className={`${isSidebarCollapsed ? 'ml-14' : 'ml-60'} transition-all duration-200`}>
|
||||
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
|
||||
</div>
|
||||
<Toolbar />
|
||||
<div
|
||||
className={`${isSidebarCollapsed ? 'pl-14' : 'pl-60'} relative h-full w-full flex-1 transition-all duration-200`}
|
||||
>
|
||||
<div className='relative h-full w-full flex-1 transition-all duration-200'>
|
||||
<div className='fixed top-0 right-0 z-10'>
|
||||
<Panel />
|
||||
<NotificationList />
|
||||
</div>
|
||||
|
||||
{/* Floating Control Bar */}
|
||||
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edgesWithSelection}
|
||||
@@ -1583,7 +1560,12 @@ const WorkflowContent = React.memo(() => {
|
||||
autoPanOnConnect={userPermissions.canEdit}
|
||||
autoPanOnNodeDrag={userPermissions.canEdit}
|
||||
>
|
||||
<Background />
|
||||
<Background
|
||||
color='hsl(var(--workflow-dots))'
|
||||
size={4}
|
||||
gap={40}
|
||||
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logger } from '@sentry/nextjs'
|
||||
import { ChevronRight, File, Folder, Plus, Upload } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { File, Folder, Plus, Upload } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { ImportControls, type ImportControlsRef } from './import-controls'
|
||||
|
||||
interface CreateMenuProps {
|
||||
onCreateWorkflow: (folderId?: string) => void
|
||||
onCreateWorkflow: (folderId?: string) => Promise<string>
|
||||
isCollapsed?: boolean
|
||||
isCreatingWorkflow?: boolean
|
||||
}
|
||||
@@ -29,10 +28,11 @@ export function CreateMenu({
|
||||
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
||||
const [folderName, setFolderName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isHoverOpen, setIsHoverOpen] = useState(false)
|
||||
const [isImportSubmenuOpen, setIsImportSubmenuOpen] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [pressTimer, setPressTimer] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { createFolder } = useFolderStore()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
@@ -40,23 +40,112 @@ export function CreateMenu({
|
||||
// Ref for the file input that will be used by ImportControls
|
||||
const importControlsRef = useRef<ImportControlsRef>(null)
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
setIsHoverOpen(false)
|
||||
onCreateWorkflow()
|
||||
const handleCreateWorkflow = useCallback(async () => {
|
||||
if (isCreatingWorkflow) {
|
||||
logger.info('Workflow creation already in progress, ignoring request')
|
||||
return
|
||||
}
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
setIsHoverOpen(false)
|
||||
setIsOpen(false)
|
||||
|
||||
try {
|
||||
// Call the parent's workflow creation function and wait for the ID
|
||||
const workflowId = await onCreateWorkflow()
|
||||
|
||||
// Navigate to the new workflow
|
||||
if (workflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating workflow:', { error })
|
||||
}
|
||||
}, [onCreateWorkflow, isCreatingWorkflow, router, workspaceId])
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setShowFolderDialog(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUploadYaml = () => {
|
||||
setIsHoverOpen(false)
|
||||
setIsImportSubmenuOpen(false)
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
// Trigger the file upload from ImportControls component
|
||||
importControlsRef.current?.triggerFileUpload()
|
||||
}, [])
|
||||
|
||||
// Handle direct click for workflow creation
|
||||
const handleButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Clear any existing press timer
|
||||
if (pressTimer) {
|
||||
window.clearTimeout(pressTimer)
|
||||
setPressTimer(null)
|
||||
}
|
||||
|
||||
// Direct workflow creation on click
|
||||
handleCreateWorkflow()
|
||||
},
|
||||
[handleCreateWorkflow, pressTimer]
|
||||
)
|
||||
|
||||
// Handle hover to show popover
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (pressTimer) {
|
||||
window.clearTimeout(pressTimer)
|
||||
setPressTimer(null)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}, [pressTimer])
|
||||
|
||||
// Handle dropdown content hover
|
||||
const handlePopoverMouseEnter = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePopoverMouseLeave = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
// Handle right-click to show popover
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
// Handle long press to show popover
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
// Left mouse button
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true)
|
||||
setPressTimer(null)
|
||||
}, 500) // 500ms for long press
|
||||
setPressTimer(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (pressTimer) {
|
||||
window.clearTimeout(pressTimer)
|
||||
setPressTimer(null)
|
||||
}
|
||||
}, [pressTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pressTimer) {
|
||||
window.clearTimeout(pressTimer)
|
||||
}
|
||||
}
|
||||
}, [pressTimer])
|
||||
|
||||
const handleFolderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!folderName.trim() || !workspaceId) return
|
||||
@@ -83,25 +172,23 @@ export function CreateMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isHoverOpen}>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 shrink-0 p-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
title='Create'
|
||||
onClick={handleCreateWorkflow}
|
||||
onMouseEnter={() => setIsHoverOpen(true)}
|
||||
onMouseLeave={() => setIsHoverOpen(false)}
|
||||
className='h-9 w-9 shrink-0 rounded-lg border bg-card shadow-xs hover:bg-accent focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
title='Create Workflow (Hover, right-click, or long press for more options)'
|
||||
disabled={isCreatingWorkflow}
|
||||
onClick={handleButtonClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Plus
|
||||
className={cn(
|
||||
'stroke-[2px]',
|
||||
isCollapsed ? 'h-[18px] w-[18px]' : 'h-[16px] w-[16px]'
|
||||
)}
|
||||
/>
|
||||
<span className='sr-only'>Create</span>
|
||||
<Plus className='h-[18px] w-[18px] stroke-[2px]' />
|
||||
<span className='sr-only'>Create Workflow</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -116,73 +203,39 @@ export function CreateMenu({
|
||||
'data-[state=closed]:animate-out',
|
||||
'w-48'
|
||||
)}
|
||||
onMouseEnter={() => setIsHoverOpen(true)}
|
||||
onMouseLeave={() => setIsHoverOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onMouseEnter={handlePopoverMouseEnter}
|
||||
onMouseLeave={handlePopoverMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
isCreatingWorkflow
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50',
|
||||
isCreatingWorkflow && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow}
|
||||
>
|
||||
<File className='h-4 w-4' />
|
||||
{isCreatingWorkflow ? 'Creating...' : 'New Workflow'}
|
||||
{isCreatingWorkflow ? 'Creating...' : 'New workflow'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
|
||||
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
onClick={handleCreateFolder}
|
||||
>
|
||||
<Folder className='h-4 w-4' />
|
||||
New Folder
|
||||
New folder
|
||||
</button>
|
||||
|
||||
{userPermissions.canEdit && (
|
||||
<>
|
||||
<Separator className='my-1' />
|
||||
|
||||
<Popover open={isImportSubmenuOpen} onOpenChange={setIsImportSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className='flex w-full cursor-default select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseEnter={() => setIsImportSubmenuOpen(true)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Upload className='h-4 w-4' />
|
||||
<span>Import Workflow</span>
|
||||
</div>
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='right'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
className='w-48 p-1'
|
||||
onMouseEnter={() => setIsImportSubmenuOpen(true)}
|
||||
onMouseLeave={() => setIsImportSubmenuOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
|
||||
onClick={handleUploadYaml}
|
||||
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
onClick={handleImportWorkflow}
|
||||
>
|
||||
<Upload className='h-4 w-4' />
|
||||
<div className='flex flex-col items-start'>
|
||||
<span>YAML</span>
|
||||
<span className='text-muted-foreground text-xs'>.yaml or .yml</span>
|
||||
</div>
|
||||
Import workflow
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -191,10 +244,7 @@ export function CreateMenu({
|
||||
<ImportControls
|
||||
ref={importControlsRef}
|
||||
disabled={!userPermissions.canEdit}
|
||||
onClose={() => {
|
||||
setIsHoverOpen(false)
|
||||
setIsImportSubmenuOpen(false)
|
||||
}}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Folder creation dialog */}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -33,7 +21,6 @@ export interface ImportControlsRef {
|
||||
export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>(
|
||||
({ disabled = false, onClose }, ref) => {
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [showYamlDialog, setShowYamlDialog] = useState(false)
|
||||
const [yamlContent, setYamlContent] = useState('')
|
||||
const [importResult, setImportResult] = useState<{
|
||||
success: boolean
|
||||
@@ -66,7 +53,9 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
try {
|
||||
const content = await file.text()
|
||||
setYamlContent(content)
|
||||
setShowYamlDialog(true)
|
||||
|
||||
// Import directly without showing the modal
|
||||
await handleDirectImport(content)
|
||||
onClose?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to read file:', error)
|
||||
@@ -85,8 +74,8 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
}
|
||||
}
|
||||
|
||||
const handleYamlImport = async () => {
|
||||
if (!yamlContent.trim()) {
|
||||
const handleDirectImport = async (content: string) => {
|
||||
if (!content.trim()) {
|
||||
setImportResult({
|
||||
success: false,
|
||||
errors: ['YAML content is required'],
|
||||
@@ -100,7 +89,7 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
|
||||
try {
|
||||
// First validate the YAML without importing
|
||||
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
|
||||
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(content)
|
||||
|
||||
if (!yamlWorkflow || parseErrors.length > 0) {
|
||||
setImportResult({
|
||||
@@ -121,13 +110,12 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
// Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB)
|
||||
// This avoids timing issues with workflow reload during navigation
|
||||
const result = await importWorkflowFromYaml(
|
||||
yamlContent,
|
||||
content,
|
||||
{
|
||||
addBlock: collaborativeAddBlock,
|
||||
addEdge: collaborativeAddEdge,
|
||||
applyAutoLayout: () => {
|
||||
// Trigger auto layout
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
// Do nothing - auto layout should not run during import
|
||||
},
|
||||
setSubBlockValue: (blockId: string, subBlockId: string, value: unknown) => {
|
||||
// Use the collaborative function - the same one called when users type into fields
|
||||
@@ -151,7 +139,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
|
||||
if (result.success) {
|
||||
setYamlContent('')
|
||||
setShowYamlDialog(false)
|
||||
logger.info('YAML import completed successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -166,8 +153,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || isImporting
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input */}
|
||||
@@ -178,103 +163,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
onChange={handleFileUpload}
|
||||
className='hidden'
|
||||
/>
|
||||
|
||||
{/* YAML Import Dialog */}
|
||||
<Dialog open={showYamlDialog} onOpenChange={setShowYamlDialog}>
|
||||
<DialogContent className='flex max-h-[80vh] max-w-4xl flex-col'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Workflow from YAML</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the YAML content below and click "Import Workflow" to create a new workflow
|
||||
with the blocks and connections defined in the YAML.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 space-y-4 overflow-hidden'>
|
||||
<Textarea
|
||||
placeholder={`version: "1.0"
|
||||
blocks:
|
||||
start:
|
||||
type: "starter"
|
||||
name: "Start"
|
||||
inputs:
|
||||
startWorkflow: "manual"
|
||||
following:
|
||||
- "process"
|
||||
|
||||
process:
|
||||
type: "agent"
|
||||
name: "Process Data"
|
||||
inputs:
|
||||
systemPrompt: "You are a helpful assistant"
|
||||
userPrompt: "Process the data"
|
||||
model: "gpt-4"
|
||||
preceding:
|
||||
- "start"`}
|
||||
value={yamlContent}
|
||||
onChange={(e) => setYamlContent(e.target.value)}
|
||||
className='min-h-[300px] font-mono text-sm'
|
||||
disabled={isImporting}
|
||||
/>
|
||||
|
||||
{/* Import Result */}
|
||||
{importResult && (
|
||||
<div className='space-y-2'>
|
||||
{importResult.success ? (
|
||||
<Alert>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='font-medium text-green-700'>Import Successful!</div>
|
||||
{importResult.summary && (
|
||||
<div className='mt-1 text-sm'>{importResult.summary}</div>
|
||||
)}
|
||||
{importResult.warnings.length > 0 && (
|
||||
<div className='mt-2'>
|
||||
<div className='font-medium text-sm'>Warnings:</div>
|
||||
<ul className='mt-1 space-y-1 text-sm'>
|
||||
{importResult.warnings.map((warning, index) => (
|
||||
<li key={index} className='text-yellow-700'>
|
||||
• {warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='font-medium'>Import Failed</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<ul className='mt-2 space-y-1 text-sm'>
|
||||
{importResult.errors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowYamlDialog(false)}
|
||||
disabled={isImporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleYamlImport} disabled={isImporting || !yamlContent.trim()}>
|
||||
{isImporting ? 'Importing...' : 'Import Workflow'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -27,6 +26,7 @@ interface FolderContextMenuProps {
|
||||
onCreateWorkflow: (folderId: string) => void
|
||||
onRename?: (folderId: string, newName: string) => void
|
||||
onDelete?: (folderId: string) => void
|
||||
level: number
|
||||
}
|
||||
|
||||
export function FolderContextMenu({
|
||||
@@ -35,6 +35,7 @@ export function FolderContextMenu({
|
||||
onCreateWorkflow,
|
||||
onRename,
|
||||
onDelete,
|
||||
level,
|
||||
}: FolderContextMenuProps) {
|
||||
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
@@ -131,33 +132,50 @@ export function FolderContextMenu({
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100'
|
||||
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className='h-3 w-3' />
|
||||
<span className='sr-only'>Folder options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
{userPermissions.canEdit && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleCreateWorkflow}>
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreateWorkflow}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<File className='mr-2 h-4 w-4' />
|
||||
New Workflow
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCreateSubfolder}>
|
||||
{level === 0 && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreateSubfolder}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Folder className='mr-2 h-4 w-4' />
|
||||
New Subfolder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleRename}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{userPermissions.canAdmin ? (
|
||||
<DropdownMenuItem onClick={handleDelete} className='text-destructive'>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -166,7 +184,7 @@ export function FolderContextMenu({
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
className='cursor-not-allowed text-muted-foreground opacity-50'
|
||||
className='cursor-not-allowed rounded-md px-3 py-2 font-[380] text-muted-foreground text-sm opacity-50'
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
@@ -196,6 +214,7 @@ export function FolderContextMenu({
|
||||
value={subfolderName}
|
||||
onChange={(e) => setSubfolderName(e.target.value)}
|
||||
placeholder='Enter folder name...'
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
@@ -226,6 +245,7 @@ export function FolderContextMenu({
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder='Enter folder name...'
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { Folder, FolderOpen } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -29,6 +29,8 @@ interface FolderItemProps {
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
onDragLeave?: (e: React.DragEvent) => void
|
||||
onDrop?: (e: React.DragEvent) => void
|
||||
isFirstItem?: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
@@ -39,10 +41,14 @@ export function FolderItem({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
isFirstItem = false,
|
||||
level,
|
||||
}: FolderItemProps) {
|
||||
const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragStartedRef = useRef(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
@@ -69,6 +75,39 @@ export function FolderItem({
|
||||
}, 300)
|
||||
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
dragStartedRef.current = true
|
||||
setIsDragging(true)
|
||||
|
||||
e.dataTransfer.setData('folder-id', folder.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
// Set global drag state for validation in other components
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as any).currentDragFolderId = folder.id
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false)
|
||||
requestAnimationFrame(() => {
|
||||
dragStartedRef.current = false
|
||||
})
|
||||
|
||||
// Clear global drag state
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as any).currentDragFolderId = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (dragStartedRef.current) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
handleToggleExpanded()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (updateTimeoutRef.current) {
|
||||
@@ -107,11 +146,17 @@ export function FolderItem({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className='group mx-auto flex h-8 w-8 cursor-pointer items-center justify-center'
|
||||
className={clsx(
|
||||
'group mx-auto mb-1 flex h-9 w-9 cursor-pointer items-center justify-center',
|
||||
isDragging ? 'opacity-50' : ''
|
||||
)}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={handleToggleExpanded}
|
||||
onClick={handleClick}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -128,7 +173,7 @@ export function FolderItem({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>
|
||||
<p>{folder.name}</p>
|
||||
<p className='max-w-[200px] break-words'>{folder.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -136,7 +181,13 @@ export function FolderItem({
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete "{folder.name}"?</AlertDialogTitle>
|
||||
<AlertDialogTitle className='break-words'>
|
||||
Are you sure you want to delete "
|
||||
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
|
||||
{folder.name}
|
||||
</span>
|
||||
"?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the folder and all its contents, including subfolders
|
||||
and workflows. This action cannot be undone.
|
||||
@@ -160,19 +211,21 @@ export function FolderItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='group' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
|
||||
<div className='group mb-1' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
|
||||
<div
|
||||
className='flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent/50'
|
||||
onClick={handleToggleExpanded}
|
||||
>
|
||||
<div className='mr-1 flex h-4 w-4 items-center justify-center'>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
className={clsx(
|
||||
'flex h-9 cursor-pointer items-center rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent/50',
|
||||
isDragging ? 'opacity-50' : '',
|
||||
isFirstItem ? 'mr-[44px]' : ''
|
||||
)}
|
||||
</div>
|
||||
|
||||
style={{
|
||||
maxWidth: isFirstItem ? `${164 - level * 20}px` : `${206 - level * 20}px`,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className='mr-2 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
|
||||
@@ -181,9 +234,7 @@ export function FolderItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className='flex-1 cursor-default select-none truncate text-muted-foreground'>
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
|
||||
|
||||
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
|
||||
<FolderContextMenu
|
||||
@@ -192,6 +243,7 @@ export function FolderItem({
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +253,13 @@ export function FolderItem({
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete "{folder.name}"?</AlertDialogTitle>
|
||||
<AlertDialogTitle className='break-words'>
|
||||
Are you sure you want to delete "
|
||||
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
|
||||
{folder.name}
|
||||
</span>
|
||||
"?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the folder and all its contents, including subfolders and
|
||||
workflows. This action cannot be undone.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { WorkflowContextMenu } from '../../workflow-context-menu/workflow-context-menu'
|
||||
|
||||
const logger = createLogger('WorkflowItem')
|
||||
|
||||
@@ -18,6 +19,7 @@ interface WorkflowItemProps {
|
||||
isCollapsed?: boolean
|
||||
level: number
|
||||
isDragOver?: boolean
|
||||
isFirstItem?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowItem({
|
||||
@@ -27,6 +29,7 @@ export function WorkflowItem({
|
||||
isCollapsed,
|
||||
level,
|
||||
isDragOver = false,
|
||||
isFirstItem = false,
|
||||
}: WorkflowItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragStartedRef = useRef(false)
|
||||
@@ -81,10 +84,11 @@ export function WorkflowItem({
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
data-workflow-id={workflow.id}
|
||||
className={clsx(
|
||||
'mx-auto flex h-8 w-8 items-center justify-center rounded-md',
|
||||
'mx-auto mb-1 flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
|
||||
active && !isDragOver
|
||||
? 'bg-accent text-accent-foreground'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50',
|
||||
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver
|
||||
? 'bg-accent/70'
|
||||
@@ -103,7 +107,7 @@ export function WorkflowItem({
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>
|
||||
<p>
|
||||
<p className='max-w-[200px] break-words'>
|
||||
{workflow.name}
|
||||
{isMarketplace && ' (Preview)'}
|
||||
</p>
|
||||
@@ -113,31 +117,49 @@ export function WorkflowItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
<div className='group mb-1'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center rounded-md px-2 py-1.5 font-medium text-sm',
|
||||
'flex h-9 items-center rounded-lg px-2 py-2 font-medium text-sm transition-colors',
|
||||
active && !isDragOver
|
||||
? 'bg-accent text-accent-foreground'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50',
|
||||
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-accent/70' : '',
|
||||
isDragging ? 'opacity-50' : '',
|
||||
!isMarketplace ? 'cursor-move' : ''
|
||||
'cursor-pointer',
|
||||
isFirstItem ? 'mr-[44px]' : ''
|
||||
)}
|
||||
style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}
|
||||
style={{
|
||||
maxWidth: isFirstItem
|
||||
? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
|
||||
: `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`,
|
||||
}}
|
||||
draggable={!isMarketplace}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-workflow-id={workflow.id}
|
||||
>
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
className='flex min-w-0 flex-1 items-center'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div
|
||||
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<span className='truncate'>
|
||||
<span className='flex-1 select-none truncate'>
|
||||
{workflow.name}
|
||||
{isMarketplace && ' (Preview)'}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{!isMarketplace && (
|
||||
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
|
||||
<WorkflowContextMenu workflow={workflow} level={level} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
@@ -18,12 +19,14 @@ interface FolderSectionProps {
|
||||
expandedFolders: Set<string>
|
||||
pathname: string
|
||||
updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void>
|
||||
updateFolder: (id: string, updates: any) => Promise<any>
|
||||
renderFolderTree: (
|
||||
nodes: FolderTreeNode[],
|
||||
level: number,
|
||||
parentDragOver?: boolean
|
||||
) => React.ReactNode[]
|
||||
parentDragOver?: boolean
|
||||
isFirstItem?: boolean
|
||||
}
|
||||
|
||||
function FolderSection({
|
||||
@@ -35,27 +38,41 @@ function FolderSection({
|
||||
expandedFolders,
|
||||
pathname,
|
||||
updateWorkflow,
|
||||
updateFolder,
|
||||
renderFolderTree,
|
||||
parentDragOver = false,
|
||||
isFirstItem = false,
|
||||
}: FolderSectionProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { isDragOver, handleDragOver, handleDragLeave, handleDrop } = useDragHandlers(
|
||||
const { isDragOver, isInvalidDrop, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useDragHandlers(
|
||||
updateWorkflow,
|
||||
updateFolder,
|
||||
folder.id,
|
||||
`Moved workflow(s) to folder ${folder.id}`
|
||||
)
|
||||
|
||||
const workflowsInFolder = workflowsByFolder[folder.id] || []
|
||||
const isAnyDragOver = isDragOver || parentDragOver
|
||||
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(isDragOver ? 'rounded-md bg-blue-500/10 dark:bg-blue-400/10' : '')}
|
||||
className={clsx(
|
||||
isDragOver
|
||||
? isInvalidDrop
|
||||
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
|
||||
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
|
||||
: ''
|
||||
)}
|
||||
style={
|
||||
isDragOver
|
||||
? {
|
||||
boxShadow: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
|
||||
boxShadow: isInvalidDrop
|
||||
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
|
||||
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
@@ -70,28 +87,136 @@ function FolderSection({
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
isFirstItem={isFirstItem}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Render children with connecting lines */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className='relative'>
|
||||
{/* Vertical line from folder icon to children */}
|
||||
{!isCollapsed && (workflowsInFolder.length > 0 || folder.children.length > 0) && (
|
||||
<div
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
left: `${level * 20 + 16}px`,
|
||||
top: '-9px',
|
||||
width: '1px',
|
||||
height: `${(workflowsInFolder.length + folder.children.length - 1) * 40 + 24}px`,
|
||||
background: 'hsl(var(--muted-foreground) / 0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render workflows in this folder */}
|
||||
{expandedFolders.has(folder.id) && workflowsInFolder.length > 0 && (
|
||||
<div className='space-y-0.5'>
|
||||
{workflowsInFolder.map((workflow) => (
|
||||
{workflowsInFolder.length > 0 && (
|
||||
<div>
|
||||
{workflowsInFolder.map((workflow, index) => (
|
||||
<div key={workflow.id} className='relative'>
|
||||
{/* Curved corner */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
left: `${level * 20 + 16}px`,
|
||||
top: '15px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
|
||||
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
|
||||
borderBottomLeftRadius: '4px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Horizontal line to workflow */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
left: `${level * 20 + 20}px`,
|
||||
top: '18px',
|
||||
width: '7px',
|
||||
height: '1px',
|
||||
background: 'hsl(var(--muted-foreground) / 0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Workflow container with proper indentation */}
|
||||
<div style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}>
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCollapsed={isCollapsed}
|
||||
level={level}
|
||||
isDragOver={isAnyDragOver}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render child folders */}
|
||||
{expandedFolders.has(folder.id) && folder.children.length > 0 && (
|
||||
<div>{renderFolderTree(folder.children, level + 1, isAnyDragOver)}</div>
|
||||
{folder.children.length > 0 && (
|
||||
<div>
|
||||
{folder.children.map((childFolder, index) => (
|
||||
<div key={childFolder.id} className='relative'>
|
||||
{/* Curved corner */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
left: `${level * 20 + 16}px`,
|
||||
top: '15px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
|
||||
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
|
||||
borderBottomLeftRadius: '4px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Horizontal line to child folder */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
left: `${level * 20 + 20}px`,
|
||||
top: '18px',
|
||||
width: '5px',
|
||||
height: '1px',
|
||||
background: 'hsl(var(--muted-foreground) / 0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ paddingLeft: isCollapsed ? '0px' : '8px' }}>
|
||||
<FolderSection
|
||||
key={childFolder.id}
|
||||
folder={childFolder}
|
||||
level={level + 1}
|
||||
isCollapsed={isCollapsed}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
expandedFolders={expandedFolders}
|
||||
pathname={pathname}
|
||||
updateWorkflow={updateWorkflow}
|
||||
updateFolder={updateFolder}
|
||||
renderFolderTree={renderFolderTree}
|
||||
parentDragOver={isAnyDragOver}
|
||||
isFirstItem={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -100,21 +225,46 @@ function FolderSection({
|
||||
// Custom hook for drag and drop handling
|
||||
function useDragHandlers(
|
||||
updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void>,
|
||||
updateFolder: (id: string, updates: any) => Promise<any>,
|
||||
targetFolderId: string | null, // null for root
|
||||
logMessage?: string
|
||||
) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [isInvalidDrop, setIsInvalidDrop] = useState(false)
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragOver(true)
|
||||
|
||||
// Check if this would be an invalid folder drop
|
||||
const draggedFolderId =
|
||||
(typeof window !== 'undefined' && (window as any).currentDragFolderId) || null
|
||||
|
||||
if (draggedFolderId && targetFolderId) {
|
||||
const folderStore = useFolderStore.getState()
|
||||
const targetFolderPath = folderStore.getFolderPath(targetFolderId)
|
||||
|
||||
// Check for circular reference
|
||||
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
|
||||
const isCircular =
|
||||
targetFolderId === draggedFolderId ||
|
||||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
|
||||
|
||||
// Check for deep nesting (target folder already has a parent)
|
||||
const wouldBeDeepNesting = targetFolderPath.length >= 1
|
||||
|
||||
setIsInvalidDrop(isCircular || wouldBeDeepNesting)
|
||||
} else {
|
||||
setIsInvalidDrop(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragOver(false)
|
||||
setIsInvalidDrop(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
@@ -122,6 +272,7 @@ function useDragHandlers(
|
||||
e.stopPropagation()
|
||||
setIsDragOver(false)
|
||||
|
||||
// Handle workflow drops
|
||||
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
|
||||
if (workflowIdsData) {
|
||||
const workflowIds = JSON.parse(workflowIdsData) as string[]
|
||||
@@ -136,10 +287,49 @@ function useDragHandlers(
|
||||
console.error('Failed to move workflows:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle folder drops
|
||||
const folderIdData = e.dataTransfer.getData('folder-id')
|
||||
if (folderIdData) {
|
||||
try {
|
||||
// Check if the target folder would create more than 2 levels of nesting
|
||||
const folderStore = useFolderStore.getState()
|
||||
const targetFolderPath = targetFolderId ? folderStore.getFolderPath(targetFolderId) : []
|
||||
|
||||
// Prevent circular references - don't allow dropping a folder into itself or its descendants
|
||||
if (targetFolderId === folderIdData) {
|
||||
console.log('Cannot move folder into itself')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if target folder is a descendant of the dragged folder
|
||||
const draggedFolderPath = folderStore.getFolderPath(folderIdData)
|
||||
if (
|
||||
targetFolderId &&
|
||||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
|
||||
) {
|
||||
console.log('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
|
||||
// If target folder is already at level 1 (has 1 parent), we can't nest another folder
|
||||
if (targetFolderPath.length >= 1) {
|
||||
console.log('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
|
||||
return // Prevent the drop entirely
|
||||
}
|
||||
|
||||
// Target folder is at root level, safe to nest
|
||||
await updateFolder(folderIdData, { parentId: targetFolderId })
|
||||
console.log(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to move folder:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
isInvalidDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
@@ -170,15 +360,53 @@ export function FolderTree({
|
||||
fetchFolders,
|
||||
isLoading: foldersLoading,
|
||||
clearSelection,
|
||||
updateFolderAPI,
|
||||
} = useFolderStore()
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
// Clean up any existing folders with 3+ levels of nesting
|
||||
const cleanupDeepNesting = useCallback(async () => {
|
||||
const { getFolderTree, updateFolderAPI } = useFolderStore.getState()
|
||||
const folderTree = getFolderTree(workspaceId)
|
||||
|
||||
const findDeepFolders = (nodes: FolderTreeNode[], currentLevel = 0): FolderTreeNode[] => {
|
||||
let deepFolders: FolderTreeNode[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (currentLevel >= 2) {
|
||||
// This folder is at level 2+ (too deep), add it to cleanup list
|
||||
deepFolders.push(node)
|
||||
} else {
|
||||
// Recursively check children
|
||||
deepFolders = deepFolders.concat(findDeepFolders(node.children, currentLevel + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return deepFolders
|
||||
}
|
||||
|
||||
const deepFolders = findDeepFolders(folderTree)
|
||||
|
||||
// Move deeply nested folders to root level
|
||||
for (const folder of deepFolders) {
|
||||
try {
|
||||
await updateFolderAPI(folder.id, { parentId: null })
|
||||
console.log(`Moved deeply nested folder "${folder.name}" to root level`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to move folder "${folder.name}":`, error)
|
||||
}
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
// Fetch folders when workspace changes
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchFolders(workspaceId)
|
||||
fetchFolders(workspaceId).then(() => {
|
||||
// Clean up any existing deep nesting after folders are loaded
|
||||
cleanupDeepNesting()
|
||||
})
|
||||
}
|
||||
}, [workspaceId, fetchFolders])
|
||||
}, [workspaceId, fetchFolders, cleanupDeepNesting])
|
||||
|
||||
useEffect(() => {
|
||||
clearSelection()
|
||||
@@ -199,17 +427,18 @@ export function FolderTree({
|
||||
|
||||
const {
|
||||
isDragOver: rootDragOver,
|
||||
isInvalidDrop: rootInvalidDrop,
|
||||
handleDragOver: handleRootDragOver,
|
||||
handleDragLeave: handleRootDragLeave,
|
||||
handleDrop: handleRootDrop,
|
||||
} = useDragHandlers(updateWorkflow, null, 'Moved workflow(s) to root')
|
||||
} = useDragHandlers(updateWorkflow, updateFolderAPI, null, 'Moved workflow(s) to root')
|
||||
|
||||
const renderFolderTree = (
|
||||
nodes: FolderTreeNode[],
|
||||
level = 0,
|
||||
parentDragOver = false
|
||||
): React.ReactNode[] => {
|
||||
return nodes.map((folder) => (
|
||||
return nodes.map((folder, index) => (
|
||||
<FolderSection
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
@@ -220,33 +449,70 @@ export function FolderTree({
|
||||
expandedFolders={expandedFolders}
|
||||
pathname={pathname}
|
||||
updateWorkflow={updateWorkflow}
|
||||
updateFolder={updateFolderAPI}
|
||||
renderFolderTree={renderFolderTree}
|
||||
parentDragOver={parentDragOver}
|
||||
isFirstItem={level === 0 && index === 0}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const showLoading = isLoading || foldersLoading
|
||||
const rootWorkflows = workflowsByFolder.root || []
|
||||
|
||||
// Render skeleton loading state
|
||||
const renderSkeletonLoading = () => {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className='space-y-1 py-2'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='mx-auto mb-1 flex h-9 w-9 items-center justify-center'>
|
||||
<Skeleton className='h-4 w-4 rounded' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`space-y-0.5 transition-opacity duration-200 ${showLoading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className='space-y-1 py-2'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='flex h-9 items-center rounded-lg px-2 py-2'>
|
||||
<Skeleton className='mr-2 h-4 w-4 rounded' />
|
||||
<Skeleton className='h-4 max-w-32 flex-1' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
return renderSkeletonLoading()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1 py-2'>
|
||||
{/* Folder tree */}
|
||||
{renderFolderTree(folderTree)}
|
||||
{renderFolderTree(folderTree, 0, false)}
|
||||
|
||||
{/* Root level workflows (no folder) */}
|
||||
<div
|
||||
className={clsx(
|
||||
'space-y-0.5',
|
||||
rootDragOver ? 'rounded-md bg-blue-500/10 dark:bg-blue-400/10' : '',
|
||||
'space-y-1',
|
||||
rootDragOver
|
||||
? rootInvalidDrop
|
||||
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
|
||||
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
|
||||
: '',
|
||||
// Always provide minimal drop zone when root is empty, but keep it subtle
|
||||
(workflowsByFolder.root || []).length === 0 ? 'min-h-2 py-1' : ''
|
||||
rootWorkflows.length === 0 ? 'min-h-2 py-1' : ''
|
||||
)}
|
||||
style={
|
||||
rootDragOver
|
||||
? {
|
||||
boxShadow: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
|
||||
boxShadow: rootInvalidDrop
|
||||
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
|
||||
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
@@ -254,7 +520,7 @@ export function FolderTree({
|
||||
onDragLeave={handleRootDragLeave}
|
||||
onDrop={handleRootDrop}
|
||||
>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
{rootWorkflows.map((workflow, index) => (
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
@@ -262,41 +528,18 @@ export function FolderTree({
|
||||
isCollapsed={isCollapsed}
|
||||
level={-1}
|
||||
isDragOver={rootDragOver}
|
||||
isFirstItem={folderTree.length === 0 && index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Marketplace workflows */}
|
||||
{marketplaceWorkflows.length > 0 && (
|
||||
<div className='mt-2 border-border/30 border-t pt-2'>
|
||||
<h3
|
||||
className={`mb-1 px-2 font-medium text-muted-foreground text-xs ${
|
||||
isCollapsed ? 'text-center' : ''
|
||||
}`}
|
||||
>
|
||||
{isCollapsed ? '' : 'Marketplace'}
|
||||
</h3>
|
||||
{marketplaceWorkflows.map((workflow) => (
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isMarketplace
|
||||
isCollapsed={isCollapsed}
|
||||
level={-1}
|
||||
isDragOver={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!showLoading &&
|
||||
regularWorkflows.length === 0 &&
|
||||
marketplaceWorkflows.length === 0 &&
|
||||
folderTree.length === 0 &&
|
||||
!isCollapsed && (
|
||||
<div className='px-2 py-1.5 text-muted-foreground text-xs'>
|
||||
<div className='break-words px-2 py-1.5 text-muted-foreground text-xs'>
|
||||
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one
|
||||
to get started.
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
interface NavSectionProps {
|
||||
children: ReactNode
|
||||
isLoading?: boolean
|
||||
itemCount?: number
|
||||
isCollapsed?: boolean
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
href?: string
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
isCollapsed?: boolean
|
||||
shortcutCommand?: string
|
||||
shortcutCommandPosition?: 'inline' | 'below'
|
||||
}
|
||||
|
||||
export function NavSection({
|
||||
children,
|
||||
isLoading = false,
|
||||
itemCount = 3,
|
||||
isCollapsed,
|
||||
}: NavSectionProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<nav className='space-y-1'>
|
||||
{Array(itemCount)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<NavItemSkeleton key={i} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
return <nav className='space-y-1'>{children}</nav>
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon,
|
||||
label,
|
||||
href,
|
||||
active,
|
||||
onClick,
|
||||
isCollapsed,
|
||||
shortcutCommand,
|
||||
shortcutCommandPosition = 'inline',
|
||||
}: NavItemProps) {
|
||||
const className = clsx(
|
||||
'flex items-center gap-2 rounded-md px-2 py-[6px] text-sm font-medium',
|
||||
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
|
||||
{
|
||||
'cursor-pointer': onClick,
|
||||
'justify-center': isCollapsed,
|
||||
'w-full': !isCollapsed,
|
||||
'w-8 mx-auto': isCollapsed,
|
||||
}
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{isCollapsed ? <div className='p-[1px]'>{icon}</div> : icon}
|
||||
{!isCollapsed && <span className='truncate'>{label}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isCollapsed) {
|
||||
if (href) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
command={shortcutCommand}
|
||||
commandPosition={shortcutCommandPosition}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onClick} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
command={shortcutCommand}
|
||||
commandPosition={shortcutCommandPosition}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItemSkeleton({ isCollapsed }: { isCollapsed?: boolean }) {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className='mx-auto flex h-8 w-8 items-center justify-center'>
|
||||
<Skeleton className='h-[18px] w-[18px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2 rounded-md px-2 py-[6px]'>
|
||||
<Skeleton className='h-[18px] w-[18px]' />
|
||||
<Skeleton className='h-4 w-24' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
NavSection.Item = NavItem
|
||||
NavSection.Skeleton = NavItemSkeleton
|
||||
@@ -209,7 +209,7 @@ export function General() {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
{/* <div className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='auto-pan' className='font-medium'>
|
||||
Auto-pan during execution
|
||||
@@ -237,7 +237,7 @@ export function General() {
|
||||
onCheckedChange={handleAutoPanChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function EditMemberLimitDialog({
|
||||
|
||||
if (newLimit < member.currentUsage) {
|
||||
setError(
|
||||
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage.toFixed(2)})`
|
||||
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage?.toFixed(2) || 0})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Current Usage</span>
|
||||
<span className='font-semibold'>
|
||||
${organizationBillingData.totalCurrentUsage.toFixed(2)}
|
||||
${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PanelRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type SidebarMode, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
// This component ONLY controls sidebar state, not toolbar state
|
||||
export function SidebarControl() {
|
||||
const { mode, setMode, toggleExpanded, isExpanded } = useSidebarStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleModeChange = (value: SidebarMode) => {
|
||||
// When selecting expanded mode, ensure it's expanded
|
||||
if (value === 'expanded' && !isExpanded) {
|
||||
toggleExpanded()
|
||||
}
|
||||
|
||||
// Set the new mode
|
||||
setMode(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground hover:bg-accent/50'
|
||||
>
|
||||
<PanelRight className='h-[18px] w-[18px] text-muted-foreground' />
|
||||
<span className='sr-only text-sm'>Sidebar control</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-44 overflow-hidden rounded-lg border bg-background p-0 shadow-md'
|
||||
side='top'
|
||||
align='start'
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className='border-b px-4 py-[10px]'>
|
||||
<h4 className='font-[480] text-muted-foreground text-xs'>Sidebar control</h4>
|
||||
</div>
|
||||
<div className='px-2 pt-1 pb-2'>
|
||||
<div className='flex flex-col gap-[1px]'>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleModeChange('expanded')}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1.5 h-1 w-1 rounded-full',
|
||||
mode === 'expanded' ? 'bg-muted-foreground' : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
Expanded
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleModeChange('collapsed')}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1.5 h-1 w-1 rounded-full',
|
||||
mode === 'collapsed' ? 'bg-muted-foreground' : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
Collapsed
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleModeChange('hover')}
|
||||
>
|
||||
<span className='flex items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1.5 h-1 w-1 rounded-full',
|
||||
mode === 'hover' ? 'bg-muted-foreground' : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
Expand on hover
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -40,28 +40,25 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
|
||||
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
|
||||
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
|
||||
style={{ backgroundColor: config.bgColor }}
|
||||
>
|
||||
<config.icon
|
||||
className={cn(
|
||||
'text-white transition-transform duration-200',
|
||||
!disabled && 'group-hover:scale-110',
|
||||
config.type === 'agent' ? 'h-[24px] w-[24px]' : 'h-[22px] w-[22px]'
|
||||
'h-[14px] w-[14px]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-[-2px] flex flex-col gap-1'>
|
||||
<h3 className='font-medium leading-none'>{config.name}</h3>
|
||||
<p className='text-muted-foreground text-sm leading-snug'>{config.description}</p>
|
||||
</div>
|
||||
<span className='font-medium text-sm leading-none'>{config.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { LoopTool } from '../../../loop-node/loop-config'
|
||||
import { LoopTool } from '../../../../../../[workflowId]/components/loop-node/loop-config'
|
||||
|
||||
type LoopToolbarItemProps = {
|
||||
disabled?: boolean
|
||||
@@ -49,27 +49,24 @@ export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemPro
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
|
||||
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
|
||||
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
|
||||
style={{ backgroundColor: LoopTool.bgColor }}
|
||||
>
|
||||
<LoopTool.icon
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] text-white transition-transform duration-200',
|
||||
'h-[14px] w-[14px] text-white transition-transform duration-200',
|
||||
!disabled && 'group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-[-2px] flex flex-col gap-1'>
|
||||
<h3 className='font-medium leading-none'>{LoopTool.name}</h3>
|
||||
<p className='text-muted-foreground text-sm leading-snug'>{LoopTool.description}</p>
|
||||
</div>
|
||||
<span className='font-medium text-sm leading-none'>{LoopTool.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { ParallelTool } from '../../../parallel-node/parallel-config'
|
||||
import { ParallelTool } from '../../../../../../[workflowId]/components/parallel-node/parallel-config'
|
||||
|
||||
type ParallelToolbarItemProps = {
|
||||
disabled?: boolean
|
||||
@@ -49,27 +49,24 @@ export default function ParallelToolbarItem({ disabled = false }: ParallelToolba
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
|
||||
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
|
||||
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
|
||||
style={{ backgroundColor: ParallelTool.bgColor }}
|
||||
>
|
||||
<ParallelTool.icon
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] text-white transition-transform duration-200',
|
||||
'h-[14px] w-[14px] text-white transition-transform duration-200',
|
||||
!disabled && 'group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-[-2px] flex flex-col gap-1'>
|
||||
<h3 className='font-medium leading-none'>{ParallelTool.name}</h3>
|
||||
<p className='text-muted-foreground text-sm leading-snug'>{ParallelTool.description}</p>
|
||||
</div>
|
||||
<span className='font-medium text-sm leading-none'>{ParallelTool.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { ToolbarBlock } from './components/toolbar-block/toolbar-block'
|
||||
import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block'
|
||||
import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block'
|
||||
|
||||
interface ToolbarProps {
|
||||
userPermissions: WorkspaceUserPermissions
|
||||
isWorkspaceSelectorVisible?: boolean
|
||||
}
|
||||
|
||||
interface BlockItem {
|
||||
name: string
|
||||
type: string
|
||||
isCustom: boolean
|
||||
config?: any
|
||||
}
|
||||
|
||||
export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: ToolbarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const { regularBlocks, specialBlocks, tools } = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
|
||||
// Filter blocks based on search query
|
||||
const filteredBlocks = allBlocks.filter((block) => {
|
||||
if (block.type === 'starter' || block.hideFromToolbar) return false
|
||||
|
||||
return (
|
||||
!searchQuery.trim() ||
|
||||
block.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
block.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// Separate regular blocks (category: 'blocks') and tools (category: 'tools')
|
||||
const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks')
|
||||
const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools')
|
||||
|
||||
// Create regular block items and sort alphabetically
|
||||
const regularBlockItems: BlockItem[] = regularBlockConfigs
|
||||
.map((block) => ({
|
||||
name: block.name,
|
||||
type: block.type,
|
||||
config: block,
|
||||
isCustom: false,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// Create special blocks (loop and parallel) if they match search
|
||||
const specialBlockItems: BlockItem[] = []
|
||||
|
||||
if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
specialBlockItems.push({
|
||||
name: 'Loop',
|
||||
type: 'loop',
|
||||
isCustom: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
specialBlockItems.push({
|
||||
name: 'Parallel',
|
||||
type: 'parallel',
|
||||
isCustom: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort special blocks alphabetically
|
||||
specialBlockItems.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// Sort tools alphabetically
|
||||
toolConfigs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return {
|
||||
regularBlocks: regularBlockItems,
|
||||
specialBlocks: specialBlockItems,
|
||||
tools: toolConfigs,
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Search */}
|
||||
<div className='flex-shrink-0 p-2'>
|
||||
<div className='flex h-9 items-center gap-2 rounded-[10px] border bg-background pr-2 pl-3'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search blocks...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='h-6 flex-1 border-0 bg-transparent px-0 font-normal text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className='flex-1 px-2' hideScrollbar={true}>
|
||||
<div className='space-y-1 pb-2'>
|
||||
{/* Regular Blocks Section */}
|
||||
{regularBlocks.map((block) => (
|
||||
<ToolbarBlock
|
||||
key={block.type}
|
||||
config={block.config}
|
||||
disabled={!userPermissions.canEdit}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Special Blocks Section (Loop & Parallel) */}
|
||||
{specialBlocks.map((block) => {
|
||||
if (block.type === 'loop') {
|
||||
return <LoopToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
|
||||
}
|
||||
if (block.type === 'parallel') {
|
||||
return <ParallelToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
|
||||
}
|
||||
return null
|
||||
})}
|
||||
|
||||
{/* Tools Section */}
|
||||
{tools.map((tool) => (
|
||||
<ToolbarBlock key={tool.type} config={tool} disabled={!userPermissions.canEdit} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MoreHorizontal, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
const logger = createLogger('WorkflowContextMenu')
|
||||
|
||||
interface WorkflowContextMenuProps {
|
||||
workflow: WorkflowMetadata
|
||||
onRename?: (workflowId: string, newName: string) => void
|
||||
level: number
|
||||
}
|
||||
|
||||
export function WorkflowContextMenu({ workflow, onRename, level }: WorkflowContextMenuProps) {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [renameName, setRenameName] = useState(workflow.name)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
|
||||
// Get user permissions for the workspace
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
const handleRename = () => {
|
||||
setRenameName(workflow.name)
|
||||
setShowRenameDialog(true)
|
||||
}
|
||||
|
||||
const handleRenameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!renameName.trim()) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
if (onRename) {
|
||||
onRename(workflow.id, renameName.trim())
|
||||
} else {
|
||||
// Default rename behavior using updateWorkflow
|
||||
await updateWorkflow(workflow.id, { name: renameName.trim() })
|
||||
logger.info(
|
||||
`Successfully renamed workflow from "${workflow.name}" to "${renameName.trim()}"`
|
||||
)
|
||||
}
|
||||
setShowRenameDialog(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename workflow:', {
|
||||
error,
|
||||
workflowId: workflow.id,
|
||||
oldName: workflow.name,
|
||||
newName: renameName.trim(),
|
||||
})
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setRenameName(workflow.name)
|
||||
setShowRenameDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className='h-3 w-3' />
|
||||
<span className='sr-only'>Workflow options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
{userPermissions.canEdit && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleRename}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Workflow</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleRenameSubmit} className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='rename-workflow'>Workflow Name</Label>
|
||||
<Input
|
||||
id='rename-workflow'
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder='Enter workflow name...'
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, Pencil, Trash2, X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, PanelLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
|
||||
/**
|
||||
* Workspace entity interface
|
||||
*/
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
@@ -44,698 +22,250 @@ interface Workspace {
|
||||
role?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Main WorkspaceHeader component props
|
||||
*/
|
||||
interface WorkspaceHeaderProps {
|
||||
onCreateWorkflow: () => void
|
||||
isCollapsed?: boolean
|
||||
onDropdownOpenChange?: (isOpen: boolean) => void
|
||||
isWorkspaceSelectorVisible: boolean
|
||||
onToggleWorkspaceSelector: () => void
|
||||
activeWorkspace: Workspace | null
|
||||
isWorkspacesLoading: boolean
|
||||
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
// New WorkspaceModal component
|
||||
interface WorkspaceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateWorkspace: (name: string) => void
|
||||
}
|
||||
|
||||
const WorkspaceModal = React.memo<WorkspaceModalProps>(
|
||||
({ open, onOpenChange, onCreateWorkspace }) => {
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (workspaceName.trim()) {
|
||||
onCreateWorkspace(workspaceName.trim())
|
||||
setWorkspaceName('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
},
|
||||
[workspaceName, onCreateWorkspace, onOpenChange]
|
||||
)
|
||||
|
||||
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWorkspaceName(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[500px]'
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Create New Workspace</DialogTitle>
|
||||
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='px-6 pt-4 pb-6'>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='workspace-name' className='font-medium text-sm'>
|
||||
Workspace Name
|
||||
</label>
|
||||
<Input
|
||||
id='workspace-name'
|
||||
value={workspaceName}
|
||||
onChange={handleNameChange}
|
||||
placeholder='Enter workspace name'
|
||||
className='w-full'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={!workspaceName.trim()}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
WorkspaceModal.displayName = 'WorkspaceModal'
|
||||
|
||||
// New WorkspaceEditModal component
|
||||
interface WorkspaceEditModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdateWorkspace: (id: string, name: string) => void
|
||||
workspace: Workspace | null
|
||||
}
|
||||
|
||||
const WorkspaceEditModal = React.memo<WorkspaceEditModalProps>(
|
||||
({ open, onOpenChange, onUpdateWorkspace, workspace }) => {
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace && open) {
|
||||
setWorkspaceName(workspace.name)
|
||||
}
|
||||
}, [workspace, open])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (workspace && workspaceName.trim()) {
|
||||
onUpdateWorkspace(workspace.id, workspaceName.trim())
|
||||
setWorkspaceName('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
},
|
||||
[workspace, workspaceName, onUpdateWorkspace, onOpenChange]
|
||||
)
|
||||
|
||||
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWorkspaceName(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[500px]'
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Edit Workspace</DialogTitle>
|
||||
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='px-6 pt-4 pb-6'>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='workspace-name-edit' className='font-medium text-sm'>
|
||||
Workspace Name
|
||||
</label>
|
||||
<Input
|
||||
id='workspace-name-edit'
|
||||
value={workspaceName}
|
||||
onChange={handleNameChange}
|
||||
placeholder='Enter workspace name'
|
||||
className='w-full'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={!workspaceName.trim()}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
WorkspaceEditModal.displayName = 'WorkspaceEditModal'
|
||||
|
||||
/**
|
||||
* WorkspaceHeader component - Single row header with all elements
|
||||
*/
|
||||
export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
({ onCreateWorkflow, isCollapsed, onDropdownOpenChange }) => {
|
||||
// Get sidebar store state to check current mode
|
||||
const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, setAnyModalOpen } =
|
||||
useSidebarStore()
|
||||
|
||||
const { data: sessionData, isPending } = useSession()
|
||||
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const getPlanName = (subscription: ReturnType<typeof getSubscriptionStatus>) => {
|
||||
if (subscription.isEnterprise) return 'Enterprise Plan'
|
||||
if (subscription.isTeam) return 'Team Plan'
|
||||
if (subscription.isPro) return 'Pro Plan'
|
||||
return 'Free Plan'
|
||||
}
|
||||
|
||||
const plan = getPlanName(subscription)
|
||||
|
||||
// Use client-side loading instead of isPending to avoid hydration mismatch
|
||||
({
|
||||
onCreateWorkflow,
|
||||
isWorkspaceSelectorVisible,
|
||||
onToggleWorkspaceSelector,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
updateWorkspaceName,
|
||||
}) => {
|
||||
// External hooks
|
||||
const { data: sessionData } = useSession()
|
||||
const [isClientLoading, setIsClientLoading] = useState(true)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
|
||||
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
|
||||
const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false)
|
||||
const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const router = useRouter()
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
|
||||
// Get workflowRegistry state and actions
|
||||
const { switchToWorkspace } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const currentWorkspaceId = params.workspaceId as string
|
||||
|
||||
// Get user permissions for the active workspace
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
// Refs
|
||||
const editInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Computed values
|
||||
const userName = useMemo(
|
||||
() => sessionData?.user?.name || sessionData?.user?.email || 'User',
|
||||
[sessionData?.user?.name, sessionData?.user?.email]
|
||||
)
|
||||
|
||||
// Set isClientLoading to false after hydration
|
||||
useEffect(() => {
|
||||
setIsClientLoading(false)
|
||||
}, [])
|
||||
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.workspaces && Array.isArray(data.workspaces)) {
|
||||
const fetchedWorkspaces = data.workspaces as Workspace[]
|
||||
setWorkspaces(fetchedWorkspaces)
|
||||
|
||||
// Only update workspace if we have a valid currentWorkspaceId from URL
|
||||
if (currentWorkspaceId) {
|
||||
const matchingWorkspace = fetchedWorkspaces.find(
|
||||
(workspace) => workspace.id === currentWorkspaceId
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
setActiveWorkspace(matchingWorkspace)
|
||||
} else {
|
||||
// Log the mismatch for debugging
|
||||
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
|
||||
|
||||
// Current workspace not found, fallback to first workspace
|
||||
if (fetchedWorkspaces.length > 0) {
|
||||
const fallbackWorkspace = fetchedWorkspaces[0]
|
||||
setActiveWorkspace(fallbackWorkspace)
|
||||
// Navigate to the fallback workspace
|
||||
router.push(`/workspace/${fallbackWorkspace.id}/w`)
|
||||
} else {
|
||||
// No workspaces available - handle this edge case
|
||||
logger.error('No workspaces available for user')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
}, [currentWorkspaceId, router])
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch subscription status if user is logged in
|
||||
if (sessionData?.user?.id) {
|
||||
fetchWorkspaces()
|
||||
}
|
||||
}, [sessionData?.user?.id, fetchWorkspaces])
|
||||
|
||||
const switchWorkspace = useCallback(
|
||||
async (workspace: Workspace) => {
|
||||
// If already on this workspace, close dropdown and do nothing else
|
||||
if (activeWorkspace?.id === workspace.id) {
|
||||
setWorkspaceDropdownOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveWorkspace(workspace)
|
||||
setWorkspaceDropdownOpen(false)
|
||||
|
||||
// Use full workspace switch which now handles localStorage automatically
|
||||
await switchToWorkspace(workspace.id)
|
||||
|
||||
// Update URL to include workspace ID - only after workspace switch completes
|
||||
router.push(`/workspace/${workspace.id}/w`)
|
||||
},
|
||||
[activeWorkspace?.id, switchToWorkspace, router, setWorkspaceDropdownOpen]
|
||||
)
|
||||
|
||||
const handleCreateWorkspace = useCallback(
|
||||
async (name: string) => {
|
||||
setIsWorkspacesLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.workspace) {
|
||||
const newWorkspace = data.workspace as Workspace
|
||||
setWorkspaces((prev) => [...prev, newWorkspace])
|
||||
setActiveWorkspace(newWorkspace)
|
||||
|
||||
// Use switchToWorkspace to properly load workflows for the new workspace
|
||||
// This will clear existing workflows, set loading state, and fetch workflows from DB
|
||||
await switchToWorkspace(newWorkspace.id)
|
||||
|
||||
// Update URL to include new workspace ID - only after workspace switch completes
|
||||
router.push(`/workspace/${newWorkspace.id}/w`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error creating workspace:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
},
|
||||
[switchToWorkspace, router]
|
||||
)
|
||||
|
||||
const handleUpdateWorkspace = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
// For update operations, we need to check permissions for the specific workspace
|
||||
// Since we can only use hooks at the component level, we'll make the API call
|
||||
// and let the backend handle the permission check
|
||||
setIsWorkspacesLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
logger.error(
|
||||
'Permission denied: Only users with admin permissions can update workspaces'
|
||||
)
|
||||
}
|
||||
throw new Error('Failed to update workspace')
|
||||
}
|
||||
|
||||
const { workspace: updatedWorkspace } = await response.json()
|
||||
|
||||
// Update workspaces list
|
||||
setWorkspaces((prevWorkspaces) =>
|
||||
prevWorkspaces.map((w) =>
|
||||
w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w
|
||||
)
|
||||
)
|
||||
|
||||
// If active workspace was updated, update it too
|
||||
if (activeWorkspace && activeWorkspace.id === updatedWorkspace.id) {
|
||||
setActiveWorkspace({
|
||||
...activeWorkspace,
|
||||
name: updatedWorkspace.name,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating workspace:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
},
|
||||
[activeWorkspace]
|
||||
)
|
||||
|
||||
const handleDeleteWorkspace = useCallback(
|
||||
async (id: string) => {
|
||||
// For delete operations, we need to check permissions for the specific workspace
|
||||
// Since we can only use hooks at the component level, we'll make the API call
|
||||
// and let the backend handle the permission check
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
logger.error(
|
||||
'Permission denied: Only users with admin permissions can delete workspaces'
|
||||
)
|
||||
}
|
||||
throw new Error('Failed to delete workspace')
|
||||
}
|
||||
|
||||
// Remove from workspace list
|
||||
const updatedWorkspaces = workspaces.filter((w) => w.id !== id)
|
||||
setWorkspaces(updatedWorkspaces)
|
||||
|
||||
// If deleted workspace was active, switch to another workspace
|
||||
if (activeWorkspace?.id === id) {
|
||||
const newWorkspace = updatedWorkspaces[0]
|
||||
setActiveWorkspace(newWorkspace)
|
||||
|
||||
// Switch to the new workspace (this handles all workflow state management)
|
||||
await switchToWorkspace(newWorkspace.id)
|
||||
|
||||
// Navigate to the new workspace - only after workspace switch completes
|
||||
router.push(`/workspace/${newWorkspace.id}/w`)
|
||||
}
|
||||
|
||||
setWorkspaceDropdownOpen(false)
|
||||
} catch (err) {
|
||||
logger.error('Error deleting workspace:', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
},
|
||||
[workspaces, activeWorkspace?.id]
|
||||
)
|
||||
|
||||
const openEditModal = useCallback(
|
||||
(workspace: Workspace, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Only show edit/delete options for the active workspace if user has admin permissions
|
||||
if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) {
|
||||
return
|
||||
}
|
||||
setEditingWorkspace(workspace)
|
||||
setIsEditModalOpen(true)
|
||||
},
|
||||
[activeWorkspace?.id, userPermissions.canAdmin]
|
||||
)
|
||||
|
||||
// Determine URL for workspace links
|
||||
const workspaceUrl = useMemo(
|
||||
() => (activeWorkspace ? `/workspace/${activeWorkspace.id}/w` : '/workspace'),
|
||||
[activeWorkspace]
|
||||
)
|
||||
|
||||
// Notify parent component when dropdown opens/closes
|
||||
const handleDropdownOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setWorkspaceDropdownOpen(open)
|
||||
// Inform the parent component about the dropdown state change
|
||||
if (onDropdownOpenChange) {
|
||||
onDropdownOpenChange(open)
|
||||
}
|
||||
},
|
||||
[onDropdownOpenChange, setWorkspaceDropdownOpen]
|
||||
const displayName = useMemo(
|
||||
() => activeWorkspace?.name || `${userName}'s Workspace`,
|
||||
[activeWorkspace?.name, userName]
|
||||
)
|
||||
|
||||
// Special handling for click interactions in hover mode
|
||||
const handleTriggerClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// When in hover mode, explicitly prevent bubbling for the trigger
|
||||
if (mode === 'hover') {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
// Toggle dropdown state
|
||||
handleDropdownOpenChange(!workspaceDropdownOpen)
|
||||
}
|
||||
},
|
||||
[mode, workspaceDropdownOpen, handleDropdownOpenChange]
|
||||
)
|
||||
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// In hover mode, prevent clicks on the container from collapsing the sidebar
|
||||
if (mode === 'hover') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
[mode]
|
||||
)
|
||||
|
||||
const handleWorkspaceModalOpenChange = useCallback((open: boolean) => {
|
||||
setIsWorkspaceModalOpen(open)
|
||||
}, [])
|
||||
|
||||
const handleEditModalOpenChange = useCallback((open: boolean) => {
|
||||
setIsEditModalOpen(open)
|
||||
}, [])
|
||||
|
||||
// Handle modal open/close state
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
// Update the modal state in the store
|
||||
setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting)
|
||||
}, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen])
|
||||
setIsClientLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='px-2 py-2'>
|
||||
{/* Workspace Modal */}
|
||||
<WorkspaceModal
|
||||
open={isWorkspaceModalOpen}
|
||||
onOpenChange={handleWorkspaceModalOpenChange}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
/>
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditingName && editInputRef.current) {
|
||||
editInputRef.current.focus()
|
||||
editInputRef.current.select()
|
||||
}
|
||||
}, [isEditingName])
|
||||
|
||||
{/* Edit Workspace Modal */}
|
||||
<WorkspaceEditModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={handleEditModalOpenChange}
|
||||
onUpdateWorkspace={handleUpdateWorkspace}
|
||||
workspace={editingWorkspace}
|
||||
/>
|
||||
// Handle toggle sidebar
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
// This will be implemented when needed - placeholder for now
|
||||
logger.info('Toggle sidebar clicked')
|
||||
}, [])
|
||||
|
||||
<DropdownMenu open={workspaceDropdownOpen} onOpenChange={handleDropdownOpenChange}>
|
||||
<div
|
||||
className={`group relative cursor-pointer rounded-md ${isCollapsed ? 'flex justify-center' : ''}`}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{/* Hover background with consistent padding - only when not collapsed */}
|
||||
{!isCollapsed && (
|
||||
<div className='absolute inset-0 rounded-md group-hover:bg-accent/50' />
|
||||
)}
|
||||
// Handle workspace name click
|
||||
const handleWorkspaceNameClick = useCallback(() => {
|
||||
setEditingName(displayName)
|
||||
setIsEditingName(true)
|
||||
}, [displayName])
|
||||
|
||||
{/* Content with consistent padding */}
|
||||
{isCollapsed ? (
|
||||
<div className='relative z-10 flex items-center justify-center px-2 py-[6px]'>
|
||||
<Link
|
||||
href={workspaceUrl}
|
||||
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
|
||||
>
|
||||
<AgentIcon className='-translate-y-[0.5px] h-[18px] w-[18px] text-white transition-all group-hover:scale-105' />
|
||||
</Link>
|
||||
// Handle workspace name editing actions
|
||||
const handleEditingAction = useCallback(
|
||||
(action: 'save' | 'cancel') => {
|
||||
switch (action) {
|
||||
case 'save': {
|
||||
// Exit edit mode immediately, save in background
|
||||
setIsEditingName(false)
|
||||
if (activeWorkspace && editingName.trim() !== '') {
|
||||
updateWorkspaceName(activeWorkspace.id, editingName.trim()).catch((error) => {
|
||||
logger.error('Failed to update workspace name:', error)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
// Cancel without saving
|
||||
setIsEditingName(false)
|
||||
setEditingName('')
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeWorkspace, editingName, updateWorkspaceName]
|
||||
)
|
||||
|
||||
// Handle keyboard interactions
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleEditingAction('save')
|
||||
} else if (e.key === 'Escape') {
|
||||
handleEditingAction('cancel')
|
||||
}
|
||||
},
|
||||
[handleEditingAction]
|
||||
)
|
||||
|
||||
// Handle click away - immediate exit with background save
|
||||
const handleInputBlur = useCallback(() => {
|
||||
handleEditingAction('save')
|
||||
}, [handleEditingAction])
|
||||
|
||||
// Render loading state
|
||||
const renderLoadingState = () => (
|
||||
<>
|
||||
{/* Icon */}
|
||||
<div className='flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'>
|
||||
<AgentIcon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
|
||||
{/* Loading workspace name and chevron container */}
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
<div className='min-w-0 flex-1 p-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
disabled
|
||||
>
|
||||
{isWorkspaceSelectorVisible ? (
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
) : (
|
||||
<div className='relative'>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className='relative z-10 flex w-full items-center px-2 py-[6px]'
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
<div className='flex cursor-pointer items-center gap-2 overflow-hidden'>
|
||||
<Link
|
||||
href={workspaceUrl}
|
||||
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
|
||||
onClick={(e) => {
|
||||
if (workspaceDropdownOpen) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<AgentIcon className='-translate-y-[0.5px] h-[18px] w-[18px] text-white transition-all group-hover:scale-105' />
|
||||
</Link>
|
||||
{isClientLoading || isWorkspacesLoading ? (
|
||||
<Skeleton className='h-4 w-[140px]' />
|
||||
) : (
|
||||
<div className='flex items-center gap-1'>
|
||||
<span className='max-w-[120px] truncate font-medium text-sm'>
|
||||
{activeWorkspace?.name || `${userName}'s Workspace`}
|
||||
</span>
|
||||
<ChevronDown className='h-3 w-3 opacity-60' />
|
||||
</div>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuContent align='start' className='min-w-[224px] p-1'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between p-1'>
|
||||
|
||||
{/* Toggle Sidebar - with gap-2 max from chevron */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded bg-[#802FFF]'>
|
||||
<AgentIcon className='h-5 w-5 text-white' />
|
||||
</div>
|
||||
<div className='flex max-w-full flex-col'>
|
||||
{isClientLoading || isWorkspacesLoading ? (
|
||||
<>
|
||||
<Skeleton className='mb-1 h-4 w-[140px]' />
|
||||
<Skeleton className='h-3 w-16' />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='truncate font-medium text-sm'>
|
||||
{activeWorkspace?.name || `${userName}'s Workspace`}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>{plan}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Workspaces list */}
|
||||
<div className='px-1 py-1'>
|
||||
<div className='mb-1 pl-1 font-medium text-muted-foreground text-xs'>Workspaces</div>
|
||||
{isWorkspacesLoading ? (
|
||||
<div className='px-2 py-1'>
|
||||
<Skeleton className='h-5 w-full' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
className={`cursor-pointer rounded-md px-2 py-1.5 text-sm ${activeWorkspace?.id === workspace.id ? 'bg-accent' : ''} group relative`}
|
||||
onClick={() => switchWorkspace(workspace)}
|
||||
>
|
||||
<span className='truncate pr-16'>{workspace.name}</span>
|
||||
{userPermissions.canAdmin && activeWorkspace?.id === workspace.id && (
|
||||
<div className='absolute right-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 p-0 text-muted-foreground'
|
||||
onClick={(e) => openEditModal(workspace, e)}
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
disabled
|
||||
>
|
||||
<Pencil className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Edit</span>
|
||||
<PanelLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 p-0 text-muted-foreground'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isDeleting || workspaces.length <= 1}
|
||||
// Render workspace info
|
||||
const renderWorkspaceInfo = () => (
|
||||
<>
|
||||
{/* Icon - separate from hover area */}
|
||||
<Link
|
||||
href={workspaceUrl}
|
||||
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
|
||||
>
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteWorkspace(workspace.id)
|
||||
<AgentIcon className='h-4 w-4 text-white transition-all group-hover:scale-105' />
|
||||
</Link>
|
||||
|
||||
{/* Workspace Name and Chevron Container */}
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
{/* Workspace Name - Editable */}
|
||||
<div className={`flex min-w-0 items-center p-1 ${isEditingName ? 'flex-1' : ''}`}>
|
||||
{isEditingName ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type='text'
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className='m-0 h-auto w-full resize-none truncate border-0 bg-transparent p-0 font-medium text-sm leading-none outline-none'
|
||||
style={{
|
||||
minHeight: '1rem',
|
||||
lineHeight: '1rem',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleWorkspaceNameClick}
|
||||
className='cursor-pointer truncate font-medium text-sm leading-none transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{
|
||||
minHeight: '1rem',
|
||||
lineHeight: '1rem',
|
||||
}}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new workspace button */}
|
||||
<DropdownMenuItem
|
||||
className='mt-1 cursor-pointer rounded-md px-2 py-1.5 text-muted-foreground text-sm'
|
||||
onClick={() => setIsWorkspaceModalOpen(true)}
|
||||
{/* Chevron - Next to name, hidden in edit mode */}
|
||||
{!isEditingName && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onToggleWorkspaceSelector}
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
>
|
||||
<span className='truncate'>+ New workspace</span>
|
||||
</DropdownMenuItem>
|
||||
{isWorkspaceSelectorVisible ? (
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Sidebar - with gap-2 max from chevron */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleToggleSidebar}
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
>
|
||||
<PanelLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>Toggle sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// Main render - using h-12 to match control bar height
|
||||
return (
|
||||
<div className='h-12 rounded-[14px] border bg-card shadow-xs'>
|
||||
<div className='flex h-full items-center gap-1 px-3'>
|
||||
{isClientLoading || isWorkspacesLoading ? renderLoadingState() : renderWorkspaceInfo()}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Send, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '../../../providers/workspace-permissions-provider'
|
||||
import { InviteModal } from './components/invite-modal/invite-modal'
|
||||
|
||||
const logger = createLogger('WorkspaceSelector')
|
||||
|
||||
/**
|
||||
* Workspace entity interface
|
||||
*/
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
membershipId?: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
interface WorkspaceSelectorProps {
|
||||
workspaces: Workspace[]
|
||||
activeWorkspace: Workspace | null
|
||||
isWorkspacesLoading: boolean
|
||||
onWorkspaceUpdate: () => Promise<void>
|
||||
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
|
||||
onCreateWorkspace: () => Promise<void>
|
||||
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
onWorkspaceUpdate,
|
||||
onSwitchWorkspace,
|
||||
onCreateWorkspace,
|
||||
onDeleteWorkspace,
|
||||
isDeleting,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// State
|
||||
const [showInviteMembers, setShowInviteMembers] = useState(false)
|
||||
const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState<string | null>(null)
|
||||
|
||||
// Refs
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/**
|
||||
* Scroll to active workspace on load or when it changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (activeWorkspace && !isWorkspacesLoading) {
|
||||
const scrollContainer = scrollAreaRef.current
|
||||
if (scrollContainer) {
|
||||
const activeButton = scrollContainer.querySelector(
|
||||
`[data-workspace-id="${activeWorkspace.id}"]`
|
||||
) as HTMLElement
|
||||
if (activeButton) {
|
||||
activeButton.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeWorkspace, isWorkspacesLoading])
|
||||
|
||||
/**
|
||||
* Confirm delete workspace
|
||||
*/
|
||||
const confirmDeleteWorkspace = useCallback(
|
||||
async (workspaceToDelete: Workspace) => {
|
||||
await onDeleteWorkspace(workspaceToDelete)
|
||||
},
|
||||
[onDeleteWorkspace]
|
||||
)
|
||||
|
||||
// Render workspace list
|
||||
const renderWorkspaceList = () => {
|
||||
if (isWorkspacesLoading) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex w-full items-center justify-between rounded-lg p-2'>
|
||||
<Skeleton className='h-[20px] w-32' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{workspaces.map((workspace) => (
|
||||
<div
|
||||
key={workspace.id}
|
||||
data-workspace-id={workspace.id}
|
||||
onMouseEnter={() => setHoveredWorkspaceId(workspace.id)}
|
||||
onMouseLeave={() => setHoveredWorkspaceId(null)}
|
||||
onClick={() => onSwitchWorkspace(workspace)}
|
||||
className={cn(
|
||||
'group flex h-9 w-full cursor-pointer items-center rounded-lg p-2 text-left transition-colors',
|
||||
activeWorkspace?.id === workspace.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full min-w-0 flex-1 items-center text-left'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-sm',
|
||||
activeWorkspace?.id === workspace.id ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex h-full w-6 flex-shrink-0 items-center justify-center'>
|
||||
{hoveredWorkspaceId === workspace.id && workspace.permissions === 'admin' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
<Trash2 className='h-2 w-2' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action cannot be
|
||||
undone and will permanently delete all workflows and data in this workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmDeleteWorkspace(workspace)}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='rounded-[14px] border bg-card shadow-xs'>
|
||||
<div className='flex h-full flex-col p-2'>
|
||||
{/* Workspace List */}
|
||||
<div className='min-h-0 flex-1'>
|
||||
<ScrollArea ref={scrollAreaRef} className='h-[116px]' hideScrollbar={true}>
|
||||
{renderWorkspaceList()}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className='mt-2 flex items-center gap-2 border-t pt-2'>
|
||||
{/* Send Invite */}
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}
|
||||
disabled={!userPermissions.canAdmin}
|
||||
className={cn(
|
||||
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs hover:bg-secondary hover:text-muted-foreground',
|
||||
!userPermissions.canAdmin && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Send className='h-3 w-3' />
|
||||
<span>Invite</span>
|
||||
</Button>
|
||||
|
||||
{/* Create Workspace */}
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={onCreateWorkspace}
|
||||
className='h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs hover:bg-secondary hover:text-muted-foreground'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
<span>Create</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Send, Settings } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getKeyboardShortcutText,
|
||||
useGlobalShortcuts,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import { CreateMenu } from './components/create-menu/create-menu'
|
||||
import { FolderTree } from './components/folder-tree/folder-tree'
|
||||
import { HelpModal } from './components/help-modal/help-modal'
|
||||
import { InviteModal } from './components/invite-modal/invite-modal'
|
||||
import { NavSection } from './components/nav-section/nav-section'
|
||||
import { SettingsModal } from './components/settings-modal/settings-modal'
|
||||
import { SidebarControl } from './components/sidebar-control/sidebar-control'
|
||||
import { Toolbar } from './components/toolbar/toolbar'
|
||||
import { WorkspaceHeader } from './components/workspace-header/workspace-header'
|
||||
import { InviteModal } from './components/workspace-selector/components/invite-modal/invite-modal'
|
||||
import { WorkspaceSelector } from './components/workspace-selector/workspace-selector'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
|
||||
const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
|
||||
|
||||
// Heights for dynamic calculation (in px)
|
||||
const SIDEBAR_HEIGHTS = {
|
||||
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
|
||||
WORKSPACE_HEADER: 48, // estimated height of workspace header
|
||||
SEARCH: 48, // h-12
|
||||
WORKFLOW_SELECTOR: 212, // h-[212px]
|
||||
NAVIGATION: 48, // h-12 buttons
|
||||
WORKSPACE_SELECTOR: 183, // accurate height: p-2(16) + h-[116px](116) + mt-2(8) + border-t(1) + pt-2(8) + h-8(32) = 181px
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace entity interface
|
||||
*/
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
membershipId?: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
useGlobalShortcuts()
|
||||
|
||||
@@ -36,41 +59,339 @@ export function Sidebar() {
|
||||
createWorkflow,
|
||||
isLoading: workflowsLoading,
|
||||
loadWorkflows,
|
||||
switchToWorkspace,
|
||||
} = useWorkflowRegistry()
|
||||
const { isPending: sessionLoading } = useSession()
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
|
||||
// Add state to prevent multiple simultaneous workflow creations
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
// Refs
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
|
||||
const isInitializedRef = useRef<boolean>(false)
|
||||
const activeWorkspaceRef = useRef<Workspace | null>(null)
|
||||
|
||||
// Update refs when values change
|
||||
workspaceIdRef.current = workspaceId
|
||||
routerRef.current = router
|
||||
|
||||
// Workspace selector visibility state
|
||||
const [isWorkspaceSelectorVisible, setIsWorkspaceSelectorVisible] = useState(false)
|
||||
|
||||
// Workspace management state
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
|
||||
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Update activeWorkspace ref when state changes
|
||||
activeWorkspaceRef.current = activeWorkspace
|
||||
|
||||
// Check if we're on a workflow page
|
||||
const isOnWorkflowPage = useMemo(() => {
|
||||
// Pattern: /workspace/[workspaceId]/w/[workflowId]
|
||||
const workflowPageRegex = /^\/workspace\/[^/]+\/w\/[^/]+$/
|
||||
return workflowPageRegex.test(pathname)
|
||||
}, [pathname])
|
||||
|
||||
/**
|
||||
* Refresh workspace list without validation logic - used for non-current workspace operations
|
||||
*/
|
||||
const refreshWorkspaceList = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.workspaces && Array.isArray(data.workspaces)) {
|
||||
const fetchedWorkspaces = data.workspaces as Workspace[]
|
||||
setWorkspaces(fetchedWorkspaces)
|
||||
|
||||
// Only update activeWorkspace if it still exists in the fetched workspaces
|
||||
// Use current state to avoid dependency on activeWorkspace
|
||||
setActiveWorkspace((currentActive) => {
|
||||
if (!currentActive) {
|
||||
return currentActive
|
||||
}
|
||||
|
||||
const matchingWorkspace = fetchedWorkspaces.find(
|
||||
(workspace) => workspace.id === currentActive.id
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
return matchingWorkspace
|
||||
}
|
||||
|
||||
// Active workspace was deleted, clear it
|
||||
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
|
||||
return null
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error refreshing workspace list:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
}, []) // Remove activeWorkspace dependency
|
||||
|
||||
/**
|
||||
* Fetch workspaces for the current user with full validation and URL handling
|
||||
*/
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.workspaces && Array.isArray(data.workspaces)) {
|
||||
const fetchedWorkspaces = data.workspaces as Workspace[]
|
||||
setWorkspaces(fetchedWorkspaces)
|
||||
|
||||
// Handle active workspace selection with URL validation using refs
|
||||
const currentWorkspaceId = workspaceIdRef.current
|
||||
const currentRouter = routerRef.current
|
||||
|
||||
if (currentWorkspaceId) {
|
||||
const matchingWorkspace = fetchedWorkspaces.find(
|
||||
(workspace) => workspace.id === currentWorkspaceId
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
setActiveWorkspace(matchingWorkspace)
|
||||
} else {
|
||||
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
|
||||
|
||||
// Fallback to first workspace if current not found - FIX: Update URL to match
|
||||
if (fetchedWorkspaces.length > 0) {
|
||||
const fallbackWorkspace = fetchedWorkspaces[0]
|
||||
setActiveWorkspace(fallbackWorkspace)
|
||||
|
||||
// Update URL to match the fallback workspace
|
||||
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
|
||||
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
|
||||
} else {
|
||||
logger.error('No workspaces available for user')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
}, []) // Remove workspaceId and router dependencies
|
||||
|
||||
/**
|
||||
* Update workspace name both in API and local state
|
||||
*/
|
||||
const updateWorkspaceName = useCallback(
|
||||
async (workspaceId: string, newName: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update workspace name')
|
||||
}
|
||||
|
||||
// Update local state immediately after successful API call
|
||||
setActiveWorkspace((prev) => (prev ? { ...prev, name: newName.trim() } : null))
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
|
||||
)
|
||||
)
|
||||
|
||||
logger.info('Successfully updated workspace name to:', newName.trim())
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace name:', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Switch to a different workspace
|
||||
*/
|
||||
const switchWorkspace = useCallback(
|
||||
async (workspace: Workspace) => {
|
||||
// If already on this workspace, return
|
||||
if (activeWorkspaceRef.current?.id === workspace.id) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch workspace and update URL
|
||||
await switchToWorkspace(workspace.id)
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/w`)
|
||||
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
|
||||
} catch (error) {
|
||||
logger.error('Error switching workspace:', error)
|
||||
}
|
||||
},
|
||||
[switchToWorkspace] // Removed activeWorkspace and router dependencies
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle create workspace
|
||||
*/
|
||||
const handleCreateWorkspace = useCallback(async () => {
|
||||
try {
|
||||
logger.info('Creating new workspace')
|
||||
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'Untitled workspace',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create workspace')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const newWorkspace = data.workspace
|
||||
|
||||
logger.info('Created new workspace:', newWorkspace)
|
||||
|
||||
// Refresh workspace list (no URL validation needed for creation)
|
||||
await refreshWorkspaceList()
|
||||
|
||||
// Switch to the new workspace
|
||||
await switchWorkspace(newWorkspace)
|
||||
} catch (error) {
|
||||
logger.error('Error creating workspace:', error)
|
||||
}
|
||||
}, [refreshWorkspaceList, switchWorkspace])
|
||||
|
||||
/**
|
||||
* Confirm delete workspace
|
||||
*/
|
||||
const confirmDeleteWorkspace = useCallback(
|
||||
async (workspaceToDelete: Workspace) => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
logger.info('Deleting workspace:', workspaceToDelete.id)
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to delete workspace')
|
||||
}
|
||||
|
||||
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
|
||||
|
||||
// Check if we're deleting the current workspace (either active or in URL)
|
||||
const isDeletingCurrentWorkspace =
|
||||
workspaceIdRef.current === workspaceToDelete.id ||
|
||||
activeWorkspaceRef.current?.id === workspaceToDelete.id
|
||||
|
||||
if (isDeletingCurrentWorkspace) {
|
||||
// For current workspace deletion, use full fetchWorkspaces with URL validation
|
||||
logger.info(
|
||||
'Deleting current workspace - using full workspace refresh with URL validation'
|
||||
)
|
||||
await fetchWorkspaces()
|
||||
|
||||
// If we deleted the active workspace, switch to the first available workspace
|
||||
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
|
||||
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-current workspace deletion, just refresh the list without URL validation
|
||||
logger.info('Deleting non-current workspace - using simple list refresh')
|
||||
await refreshWorkspaceList()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workspace:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
},
|
||||
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate workspace exists before making API calls
|
||||
*/
|
||||
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load workflows for the current workspace when workspaceId changes
|
||||
// This is the single source of truth for workflow loading
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
// Validate workspace exists before loading workflows
|
||||
isWorkspaceValid(workspaceId).then((valid) => {
|
||||
if (valid) {
|
||||
loadWorkflows(workspaceId)
|
||||
} else {
|
||||
logger.warn(`Workspace ${workspaceId} no longer exists, triggering workspace refresh`)
|
||||
fetchWorkspaces() // This will handle the redirect through the fallback logic
|
||||
}
|
||||
}, [workspaceId, loadWorkflows])
|
||||
})
|
||||
}
|
||||
}, [workspaceId, loadWorkflows]) // Removed isWorkspaceValid and fetchWorkspaces dependencies
|
||||
|
||||
// Initialize workspace data on mount (uses full validation with URL handling)
|
||||
useEffect(() => {
|
||||
if (sessionData?.user?.id && !isInitializedRef.current) {
|
||||
isInitializedRef.current = true
|
||||
fetchWorkspaces()
|
||||
}
|
||||
}, [sessionData?.user?.id]) // Removed fetchWorkspaces dependency
|
||||
|
||||
// Scroll to active workflow when it changes
|
||||
useEffect(() => {
|
||||
if (workflowId && !isLoading) {
|
||||
const scrollContainer = workflowScrollAreaRef.current
|
||||
if (scrollContainer) {
|
||||
const activeWorkflow = scrollContainer.querySelector(
|
||||
`[data-workflow-id="${workflowId}"]`
|
||||
) as HTMLElement
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workflowId, isLoading])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showInviteMembers, setShowInviteMembers] = useState(false)
|
||||
const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, isAnyModalOpen, setAnyModalOpen } =
|
||||
useSidebarStore()
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [explicitMouseEnter, setExplicitMouseEnter] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const anyModalIsOpen = showSettings || showHelp || showInviteMembers
|
||||
setAnyModalOpen(anyModalIsOpen)
|
||||
if (anyModalIsOpen) {
|
||||
setExplicitMouseEnter(false)
|
||||
}
|
||||
}, [showSettings, showHelp, showInviteMembers, setAnyModalOpen])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Separate regular workflows from temporary marketplace workflows
|
||||
const { regularWorkflows, tempWorkflows } = useMemo(() => {
|
||||
@@ -109,11 +430,10 @@ export function Sidebar() {
|
||||
}, [workflows, isLoading, workspaceId])
|
||||
|
||||
// Create workflow handler
|
||||
const handleCreateWorkflow = async (folderId?: string) => {
|
||||
// Prevent multiple simultaneous workflow creations
|
||||
const handleCreateWorkflow = async (folderId?: string): Promise<string> => {
|
||||
if (isCreatingWorkflow) {
|
||||
logger.info('Workflow creation already in progress, ignoring request')
|
||||
return
|
||||
throw new Error('Workflow creation already in progress')
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -122,226 +442,273 @@ export function Sidebar() {
|
||||
workspaceId: workspaceId || undefined,
|
||||
folderId: folderId || undefined,
|
||||
})
|
||||
router.push(`/workspace/${workspaceId}/w/${id}`)
|
||||
return id
|
||||
} catch (error) {
|
||||
logger.error('Error creating workflow:', error)
|
||||
throw error
|
||||
} finally {
|
||||
setIsCreatingWorkflow(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sidebar visibility states
|
||||
// When in hover mode, sidebar is collapsed until hovered or workspace dropdown is open
|
||||
// When in expanded/collapsed mode, sidebar follows isExpanded state
|
||||
const isCollapsed =
|
||||
mode === 'collapsed' ||
|
||||
(mode === 'hover' &&
|
||||
((!isHovered && !workspaceDropdownOpen) || isAnyModalOpen || !explicitMouseEnter))
|
||||
// Toggle workspace selector visibility
|
||||
const toggleWorkspaceSelector = () => {
|
||||
setIsWorkspaceSelectorVisible((prev) => !prev)
|
||||
}
|
||||
|
||||
const showOverlay =
|
||||
mode === 'hover' &&
|
||||
((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen)
|
||||
// Calculate dynamic positions for floating elements
|
||||
const calculateFloatingPositions = useCallback(() => {
|
||||
const { CONTAINER_PADDING, WORKSPACE_HEADER, SEARCH, WORKFLOW_SELECTOR, WORKSPACE_SELECTOR } =
|
||||
SIDEBAR_HEIGHTS
|
||||
|
||||
// Start from top padding
|
||||
let currentTop = CONTAINER_PADDING
|
||||
|
||||
// Add workspace header
|
||||
currentTop += WORKSPACE_HEADER + SIDEBAR_GAP
|
||||
|
||||
// Add workspace selector if visible
|
||||
if (isWorkspaceSelectorVisible) {
|
||||
currentTop += WORKSPACE_SELECTOR + SIDEBAR_GAP
|
||||
}
|
||||
|
||||
// Add search
|
||||
currentTop += SEARCH + SIDEBAR_GAP
|
||||
|
||||
// Add workflow selector
|
||||
currentTop += WORKFLOW_SELECTOR - 4
|
||||
|
||||
// Toolbar position (for workflow pages) - consistent with sidebar spacing
|
||||
const toolbarTop = currentTop
|
||||
|
||||
// Navigation position (always at bottom) - 16px spacing (space-4)
|
||||
const navigationBottom = 16
|
||||
|
||||
return {
|
||||
toolbarTop,
|
||||
navigationBottom,
|
||||
}
|
||||
}, [isWorkspaceSelectorVisible])
|
||||
|
||||
const { toolbarTop, navigationBottom } = calculateFloatingPositions()
|
||||
|
||||
// Navigation items with their respective actions
|
||||
const navigationItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: Settings,
|
||||
onClick: () => setShowSettings(true),
|
||||
tooltip: 'Settings',
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setShowHelp(true),
|
||||
tooltip: 'Help',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
tooltip: 'Logs',
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
icon: LibraryBig,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
tooltip: 'Knowledge',
|
||||
shortcut: getKeyboardShortcutText('K', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
tooltip: 'Templates',
|
||||
shortcut: getKeyboardShortcutText('T', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background transition-all duration-200 sm:flex',
|
||||
isCollapsed ? 'w-14' : 'w-60',
|
||||
showOverlay && 'shadow-lg',
|
||||
mode === 'hover' && 'main-content-overlay'
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (mode === 'hover' && !isAnyModalOpen) {
|
||||
setIsHovered(true)
|
||||
setExplicitMouseEnter(true)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (mode === 'hover') {
|
||||
setIsHovered(false)
|
||||
}
|
||||
}}
|
||||
<>
|
||||
{/* Main Sidebar - Overlay */}
|
||||
<aside className='pointer-events-none fixed inset-y-0 left-0 z-10 w-64'>
|
||||
<div
|
||||
className='pointer-events-none flex h-full flex-col p-4'
|
||||
style={{ gap: `${SIDEBAR_GAP}px` }}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0'>
|
||||
{/* 1. Workspace Header */}
|
||||
<div className='pointer-events-auto flex-shrink-0'>
|
||||
<WorkspaceHeader
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
isCollapsed={isCollapsed}
|
||||
onDropdownOpenChange={setWorkspaceDropdownOpen}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
onToggleWorkspaceSelector={toggleWorkspaceSelector}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
updateWorkspaceName={updateWorkspaceName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content Area */}
|
||||
<div className='scrollbar-none flex flex-1 flex-col overflow-auto px-2 py-0'>
|
||||
{/* Workflows Section */}
|
||||
<div className='flex-shrink-0'>
|
||||
<div
|
||||
className={`${isCollapsed ? 'justify-center' : ''} mb-1 flex items-center justify-between px-2`}
|
||||
>
|
||||
<h2
|
||||
className={`${isCollapsed ? 'hidden' : ''} font-medium text-muted-foreground text-xs`}
|
||||
>
|
||||
{isLoading ? <Skeleton className='h-4 w-16' /> : 'Workflows'}
|
||||
</h2>
|
||||
{!isCollapsed && !isLoading && (
|
||||
{/* 2. Workspace Selector - Conditionally rendered */}
|
||||
{isWorkspaceSelectorVisible && (
|
||||
<div className='pointer-events-auto flex-shrink-0'>
|
||||
<WorkspaceSelector
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
onWorkspaceUpdate={refreshWorkspaceList}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Search */}
|
||||
{/* <div className='pointer-events-auto flex-shrink-0'>
|
||||
<div className='flex h-12 items-center gap-2 rounded-[14px] border bg-card pr-2 pl-3 shadow-xs'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search anything'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='h-8 flex-1 border-0 bg-transparent px-0 font-normal text-base text-muted-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<kbd className='flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
|
||||
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
|
||||
<span className='text-lg'>⌘</span>
|
||||
<span className='text-xs'>K</span>
|
||||
</span>
|
||||
</kbd>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 4. Workflow Selector */}
|
||||
<div className='pointer-events-auto relative h-[272px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs'>
|
||||
<div className='px-2'>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[270px]' hideScrollbar={true}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
isCollapsed={false}
|
||||
isLoading={isLoading}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<div className='absolute top-2 right-2'>
|
||||
<CreateMenu
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
isCollapsed={false}
|
||||
isCreatingWorkflow={isCreatingWorkflow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
isCollapsed={isCollapsed}
|
||||
isLoading={isLoading}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Floating Toolbar - Only on workflow pages */}
|
||||
{isOnWorkflowPage && (
|
||||
<div
|
||||
className='pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs'
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
userPermissions={userPermissions}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Section */}
|
||||
<div className='mt-6 flex-shrink-0'>
|
||||
<NavSection isLoading={isLoading} itemCount={3} isCollapsed={isCollapsed}>
|
||||
<NavSection.Item
|
||||
icon={<ScrollText className='h-[18px] w-[18px]' />}
|
||||
href={`/workspace/${workspaceId}/logs`}
|
||||
label='Logs'
|
||||
active={pathname === `/workspace/${workspaceId}/logs`}
|
||||
isCollapsed={isCollapsed}
|
||||
shortcutCommand={getKeyboardShortcutText('L', true, true)}
|
||||
shortcutCommandPosition='below'
|
||||
/>
|
||||
<NavSection.Item
|
||||
icon={<LibraryBig className='h-[18px] w-[18px]' />}
|
||||
href={`/workspace/${workspaceId}/knowledge`}
|
||||
label='Knowledge'
|
||||
active={pathname === `/workspace/${workspaceId}/knowledge`}
|
||||
isCollapsed={isCollapsed}
|
||||
shortcutCommand={getKeyboardShortcutText('K', true, true)}
|
||||
shortcutCommandPosition='below'
|
||||
/>
|
||||
<NavSection.Item
|
||||
icon={<Settings className='h-[18px] w-[18px]' />}
|
||||
onClick={() => setShowSettings(true)}
|
||||
label='Settings'
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</NavSection>
|
||||
</div>
|
||||
|
||||
<div className='flex-grow' />
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
{isCollapsed ? (
|
||||
<div className='flex-shrink-0 px-3 pt-1 pb-3'>
|
||||
<div className='flex flex-col space-y-[1px]'>
|
||||
{!isDev && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Floating Navigation - Always visible */}
|
||||
<div
|
||||
onClick={
|
||||
userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined
|
||||
}
|
||||
className={clsx(
|
||||
'mx-auto flex h-8 w-8 items-center justify-center rounded-md font-medium text-sm',
|
||||
userPermissions.canAdmin
|
||||
? 'cursor-pointer text-muted-foreground hover:bg-accent/50'
|
||||
: 'cursor-not-allowed text-muted-foreground/50'
|
||||
)}
|
||||
className='pointer-events-auto fixed left-4 z-50 w-56'
|
||||
style={{ bottom: `${navigationBottom}px` }}
|
||||
>
|
||||
<Send className='h-[18px] w-[18px]' />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>
|
||||
{userPermissions.canAdmin
|
||||
? 'Invite Members'
|
||||
: 'Admin permission required to invite members'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => setShowHelp(true)}
|
||||
className='mx-auto flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50'
|
||||
>
|
||||
<HelpCircle className='h-[18px] w-[18px]' />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>Help</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarControl />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>Toggle sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className='flex items-center gap-1'>
|
||||
{navigationItems.map((item) => (
|
||||
<NavigationItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isDev && (
|
||||
<div className='flex-shrink-0 px-3 pt-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={
|
||||
userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined
|
||||
}
|
||||
className={clsx(
|
||||
'flex items-center rounded-md px-2 py-1.5 font-medium text-sm',
|
||||
userPermissions.canAdmin
|
||||
? 'cursor-pointer text-muted-foreground hover:bg-accent/50'
|
||||
: 'cursor-not-allowed text-muted-foreground/50'
|
||||
)}
|
||||
>
|
||||
<Send className='h-[18px] w-[18px]' />
|
||||
<span className='ml-2'>Invite members</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{userPermissions.canAdmin
|
||||
? 'Invite new members to this workspace'
|
||||
: 'Admin permission required to invite members'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex-shrink-0 px-3 pt-1 pb-3'>
|
||||
<div className='flex justify-between'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarControl />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>Toggle sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => setShowHelp(true)}
|
||||
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50'
|
||||
>
|
||||
<HelpCircle className='h-[18px] w-[18px]' />
|
||||
<span className='sr-only'>Help</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>Help, contact</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
|
||||
{!isDev && <InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />}
|
||||
</aside>
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation Item Component
|
||||
interface NavigationItemProps {
|
||||
item: {
|
||||
id: string
|
||||
icon: React.ElementType
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
tooltip: string
|
||||
shortcut?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const NavigationItem = ({ item }: NavigationItemProps) => {
|
||||
// Settings and help buttons get gray hover, others get purple hover
|
||||
const isGrayHover = item.id === 'settings' || item.id === 'help'
|
||||
|
||||
const content = item.disabled ? (
|
||||
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'h-[42px] w-[42px] rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200',
|
||||
isGrayHover && 'hover:bg-secondary',
|
||||
!isGrayHover && 'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
|
||||
item.active && 'border-[#701FFC] bg-[#701FFC] text-white'
|
||||
)}
|
||||
>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (item.href && !item.disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href={item.href} className='inline-block'>
|
||||
{content}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
|
||||
<span>{item.tooltip}</span>
|
||||
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
|
||||
<span>{item.tooltip}</span>
|
||||
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,53 +58,40 @@ export function WorkflowPreview({
|
||||
defaultZoom,
|
||||
onNodeClick,
|
||||
}: WorkflowPreviewProps) {
|
||||
// Handle migrated logs that don't have complete workflow state
|
||||
if (!workflowState || !workflowState.blocks || !workflowState.edges) {
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
|
||||
>
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ Logged State Not Found</div>
|
||||
<div className='text-sm'>
|
||||
This log was migrated from the old system and doesn't contain workflow state data.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const blocksStructure = useMemo(
|
||||
() => ({
|
||||
// Check if the workflow state is valid
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.blocks || {}).length,
|
||||
ids: Object.keys(workflowState.blocks || {}).join(','),
|
||||
}),
|
||||
[workflowState.blocks]
|
||||
)
|
||||
}
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
|
||||
const loopsStructure = useMemo(
|
||||
() => ({
|
||||
const loopsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.loops || {}).length,
|
||||
ids: Object.keys(workflowState.loops || {}).join(','),
|
||||
}),
|
||||
[workflowState.loops]
|
||||
)
|
||||
}
|
||||
}, [workflowState.loops, isValidWorkflowState])
|
||||
|
||||
const parallelsStructure = useMemo(
|
||||
() => ({
|
||||
const parallelsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.parallels || {}).length,
|
||||
ids: Object.keys(workflowState.parallels || {}).join(','),
|
||||
}),
|
||||
[workflowState.parallels]
|
||||
)
|
||||
}
|
||||
}, [workflowState.parallels, isValidWorkflowState])
|
||||
|
||||
const edgesStructure = useMemo(
|
||||
() => ({
|
||||
const edgesStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: workflowState.edges?.length || 0,
|
||||
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
|
||||
}),
|
||||
[workflowState.edges]
|
||||
)
|
||||
}
|
||||
}, [workflowState.edges, isValidWorkflowState])
|
||||
|
||||
const calculateAbsolutePosition = (
|
||||
block: any,
|
||||
@@ -129,6 +116,8 @@ export function WorkflowPreview({
|
||||
}
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
const nodeArray: Node[] = []
|
||||
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
@@ -236,9 +225,18 @@ export function WorkflowPreview({
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks])
|
||||
}, [
|
||||
blocksStructure,
|
||||
loopsStructure,
|
||||
parallelsStructure,
|
||||
showSubBlocks,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
])
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
return (workflowState.edges || []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
@@ -247,7 +245,24 @@ export function WorkflowPreview({
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'workflowEdge',
|
||||
}))
|
||||
}, [edgesStructure, workflowState.edges])
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState])
|
||||
|
||||
// Handle migrated logs that don't have complete workflow state
|
||||
if (!isValidWorkflowState) {
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
|
||||
>
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
<div className='mb-2 font-medium text-lg'>⚠️ Logged State Not Found</div>
|
||||
<div className='text-sm'>
|
||||
This log was migrated from the old system and doesn't contain workflow state data.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
|
||||
66
apps/sim/components/ui/color-picker.tsx
Normal file
66
apps/sim/components/ui/color-picker.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useMemo, useState } from 'react'
|
||||
import { HexColorPicker } from 'react-colorful'
|
||||
import type { ButtonProps } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useForwardedRef } from '@/lib/use-forwarded-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onBlur?: () => void
|
||||
}
|
||||
|
||||
const ColorPicker = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ButtonProps, 'value' | 'onChange' | 'onBlur'> & ColorPickerProps & ButtonProps
|
||||
>(({ disabled, value, onChange, onBlur, name, className, size, ...props }, forwardedRef) => {
|
||||
const ref = useForwardedRef(forwardedRef)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const parsedValue = useMemo(() => {
|
||||
return value || '#FFFFFF'
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
|
||||
<Button
|
||||
{...props}
|
||||
className={cn('block', className)}
|
||||
name={name}
|
||||
onClick={() => {
|
||||
setOpen(true)
|
||||
}}
|
||||
size={size}
|
||||
style={{
|
||||
backgroundColor: parsedValue,
|
||||
}}
|
||||
variant='outline'
|
||||
>
|
||||
<div />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-full'>
|
||||
<div className='space-y-3'>
|
||||
<HexColorPicker color={parsedValue} onChange={onChange} />
|
||||
<Input
|
||||
maxLength={7}
|
||||
onChange={(e) => {
|
||||
onChange(e?.currentTarget?.value)
|
||||
}}
|
||||
ref={ref}
|
||||
value={parsedValue}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
ColorPicker.displayName = 'ColorPicker'
|
||||
|
||||
export { ColorPicker }
|
||||
@@ -6,8 +6,10 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
hideScrollbar?: boolean
|
||||
}
|
||||
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
@@ -16,7 +18,7 @@ const ScrollArea = React.forwardRef<
|
||||
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar hidden={hideScrollbar} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -24,8 +26,10 @@ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {
|
||||
hidden?: boolean
|
||||
}
|
||||
>(({ className, orientation = 'vertical', hidden = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
@@ -33,11 +37,14 @@ const ScrollBar = React.forwardRef<
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
hidden && 'pointer-events-none w-0 border-0 p-0 opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-border' />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
className={cn('relative flex-1 rounded-full bg-border', hidden && 'hidden')}
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
@@ -258,12 +258,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Create variable tags
|
||||
const variableTags = workflowVariables.map(
|
||||
// Create variable tags - filter out variables with empty names
|
||||
const validVariables = workflowVariables.filter(
|
||||
(variable: Variable) => variable.name.trim() !== ''
|
||||
)
|
||||
|
||||
const variableTags = validVariables.map(
|
||||
(variable: Variable) => `variable.${variable.name.replace(/\s+/g, '')}`
|
||||
)
|
||||
|
||||
const variableInfoMap = workflowVariables.reduce(
|
||||
const variableInfoMap = validVariables.reduce(
|
||||
(acc, variable) => {
|
||||
const tagName = `variable.${variable.name.replace(/\s+/g, '')}`
|
||||
acc[tagName] = {
|
||||
|
||||
@@ -1036,3 +1036,83 @@ export const copilotChats = pgTable(
|
||||
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),
|
||||
})
|
||||
)
|
||||
|
||||
export const templates = pgTable(
|
||||
'templates',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workflowId: text('workflow_id')
|
||||
.notNull()
|
||||
.references(() => workflow.id),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
author: text('author').notNull(),
|
||||
views: integer('views').notNull().default(0),
|
||||
stars: integer('stars').notNull().default(0),
|
||||
color: text('color').notNull().default('#3972F6'),
|
||||
icon: text('icon').notNull().default('FileText'), // Lucide icon name as string
|
||||
category: text('category').notNull(),
|
||||
state: jsonb('state').notNull(), // Using jsonb for better performance
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Primary access patterns
|
||||
workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId),
|
||||
userIdIdx: index('templates_user_id_idx').on(table.userId),
|
||||
categoryIdx: index('templates_category_idx').on(table.category),
|
||||
|
||||
// Sorting indexes for popular/trending templates
|
||||
viewsIdx: index('templates_views_idx').on(table.views),
|
||||
starsIdx: index('templates_stars_idx').on(table.stars),
|
||||
|
||||
// Composite indexes for common queries
|
||||
categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views),
|
||||
categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars),
|
||||
userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category),
|
||||
|
||||
// Temporal indexes
|
||||
createdAtIdx: index('templates_created_at_idx').on(table.createdAt),
|
||||
updatedAtIdx: index('templates_updated_at_idx').on(table.updatedAt),
|
||||
})
|
||||
)
|
||||
|
||||
export const templateStars = pgTable(
|
||||
'template_stars',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
templateId: text('template_id')
|
||||
.notNull()
|
||||
.references(() => templates.id, { onDelete: 'cascade' }),
|
||||
starredAt: timestamp('starred_at').notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Primary access patterns
|
||||
userIdIdx: index('template_stars_user_id_idx').on(table.userId),
|
||||
templateIdIdx: index('template_stars_template_id_idx').on(table.templateId),
|
||||
|
||||
// Composite indexes for common queries
|
||||
userTemplateIdx: index('template_stars_user_template_idx').on(table.userId, table.templateId),
|
||||
templateUserIdx: index('template_stars_template_user_idx').on(table.templateId, table.userId),
|
||||
|
||||
// Temporal indexes for analytics
|
||||
starredAtIdx: index('template_stars_starred_at_idx').on(table.starredAt),
|
||||
templateStarredAtIdx: index('template_stars_template_starred_at_idx').on(
|
||||
table.templateId,
|
||||
table.starredAt
|
||||
),
|
||||
|
||||
// Uniqueness constraint - prevent duplicate stars
|
||||
uniqueUserTemplateConstraint: uniqueIndex('template_stars_user_template_unique').on(
|
||||
table.userId,
|
||||
table.templateId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1347,6 +1347,7 @@ export class Executor {
|
||||
// For streaming blocks, we'll add the console entry after stream processing
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: blockLog.output,
|
||||
success: true,
|
||||
durationMs: blockLog.durationMs,
|
||||
@@ -1416,6 +1417,7 @@ export class Executor {
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: blockLog.output,
|
||||
success: true,
|
||||
durationMs: blockLog.durationMs,
|
||||
@@ -1483,6 +1485,7 @@ export class Executor {
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: {},
|
||||
success: false,
|
||||
error:
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
|
||||
currentUsage: usageData.currentUsage,
|
||||
limit: usageData.limit,
|
||||
message: usageData.isExceeded
|
||||
? `Usage limit exceeded: ${usageData.currentUsage.toFixed(2)}$ used of ${usageData.limit}$ limit. Please upgrade your plan to continue.`
|
||||
? `Usage limit exceeded: ${usageData.currentUsage?.toFixed(2) || 0}$ used of ${usageData.limit?.toFixed(2) || 0}$ limit. Please upgrade your plan to continue.`
|
||||
: undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
25
apps/sim/lib/use-forwarded-ref.ts
Normal file
25
apps/sim/lib/use-forwarded-ref.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* A hook that handles forwarded refs and returns a mutable ref object
|
||||
* Useful for components that need both a forwarded ref and a local ref
|
||||
* @param forwardedRef The forwarded ref from React.forwardRef
|
||||
* @returns A mutable ref object that can be used locally
|
||||
*/
|
||||
export function useForwardedRef<T>(
|
||||
forwardedRef: React.ForwardedRef<T>
|
||||
): MutableRefObject<T | null> {
|
||||
const innerRef = useRef<T | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!forwardedRef) return
|
||||
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(innerRef.current)
|
||||
} else {
|
||||
forwardedRef.current = innerRef.current
|
||||
}
|
||||
}, [forwardedRef])
|
||||
|
||||
return innerRef
|
||||
}
|
||||
29
apps/sim/lib/workflows/state-builder.ts
Normal file
29
apps/sim/lib/workflows/state-builder.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Build workflow state in the same format as the deployment process
|
||||
* This utility ensures consistent state format between template creation and deployment
|
||||
*/
|
||||
export function buildWorkflowStateForTemplate(workflowId: string) {
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
// Get current workflow state
|
||||
const { blocks, edges } = workflowStore
|
||||
|
||||
// Generate loops and parallels in the same format as deployment
|
||||
const loops = workflowStore.generateLoopBlocks()
|
||||
const parallels = workflowStore.generateParallelBlocks()
|
||||
|
||||
// Build the state object in the same format as deployment
|
||||
const state = {
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
"@browserbasehq/stagehand": "^2.0.0",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@next/font": "14.2.15",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-collector": "^0.25.0",
|
||||
"@opentelemetry/exporter-jaeger": "^2.0.0",
|
||||
@@ -82,6 +83,7 @@
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"freestyle-sandboxes": "^0.0.38",
|
||||
"geist": "1.4.2",
|
||||
"fuse.js": "7.1.0",
|
||||
"groq-sdk": "^0.15.0",
|
||||
"input-otp": "^1.4.2",
|
||||
@@ -100,6 +102,7 @@
|
||||
"postgres": "^3.4.5",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-google-drive-picker": "^1.2.2",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { SocketProvider } from '@/contexts/socket-context'
|
||||
|
||||
interface WorkspaceProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
|
||||
const session = useSession()
|
||||
|
||||
const user = session.data?.user
|
||||
? {
|
||||
id: session.data.user.id,
|
||||
name: session.data.user.name,
|
||||
email: session.data.user.email,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return <SocketProvider user={user}>{children}</SocketProvider>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user