diff --git a/sim/app/api/marketplace/[id]/star/route.ts b/sim/app/api/marketplace/[id]/star/route.ts deleted file mode 100644 index 2c115e89fe..0000000000 --- a/sim/app/api/marketplace/[id]/star/route.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { NextRequest } from 'next/server' -import { and, eq } from 'drizzle-orm' -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 * as schema from '@/db/schema' - -const logger = createLogger('MarketplaceStarAPI') - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - const session = await getSession() - const userId = session?.user?.id - - if (!userId) { - return createErrorResponse('Unauthorized', 401) - } - - // Check if the marketplace entry exists - const marketplaceEntry = await db - .select() - .from(schema.marketplace) - .where(eq(schema.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 has already starred this workflow - const existingStar = await db - .select() - .from(schema.marketplaceStar) - .where( - and(eq(schema.marketplaceStar.marketplaceId, id), eq(schema.marketplaceStar.userId, userId)) - ) - .limit(1) - .then((rows) => rows[0]) - - let action - if (existingStar) { - // User has already starred, so unstar it - await db - .delete(schema.marketplaceStar) - .where( - and( - eq(schema.marketplaceStar.marketplaceId, id), - eq(schema.marketplaceStar.userId, userId) - ) - ) - - // Decrement the star count - await db - .update(schema.marketplace) - .set({ stars: marketplaceEntry.stars - 1 }) - .where(eq(schema.marketplace.id, id)) - - action = 'unstarred' - } else { - // User hasn't starred yet, add a star - await db.insert(schema.marketplaceStar).values({ - id: crypto.randomUUID(), - marketplaceId: id, - userId: userId, - createdAt: new Date(), - }) - - // Increment the star count - await db - .update(schema.marketplace) - .set({ stars: marketplaceEntry.stars + 1 }) - .where(eq(schema.marketplace.id, id)) - - action = 'starred' - } - - logger.info(`[${requestId}] User ${userId} ${action} marketplace entry: ${id}`) - - return createSuccessResponse({ - success: true, - action, - stars: action === 'starred' ? marketplaceEntry.stars + 1 : marketplaceEntry.stars - 1, - }) - } catch (error) { - logger.error(`[${requestId}] Error starring marketplace entry: ${(await params).id}`, error) - return createErrorResponse('Failed to update star status', 500) - } -} - -// GET endpoint to check if user has starred a workflow -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - const session = await getSession() - const userId = session?.user?.id - - if (!userId) { - return createErrorResponse('Unauthorized', 401) - } - - // Check if the user has already starred this workflow - const existingStar = await db - .select() - .from(schema.marketplaceStar) - .where( - and(eq(schema.marketplaceStar.marketplaceId, id), eq(schema.marketplaceStar.userId, userId)) - ) - .limit(1) - .then((rows) => rows[0]) - - return createSuccessResponse({ - isStarred: !!existingStar, - }) - } catch (error) { - logger.error(`[${requestId}] Error checking star status: ${(await params).id}`, error) - return createErrorResponse('Failed to check star status', 500) - } -} diff --git a/sim/app/api/marketplace/[id]/state/route.ts b/sim/app/api/marketplace/[id]/state/route.ts deleted file mode 100644 index 6f7e9ca353..0000000000 --- a/sim/app/api/marketplace/[id]/state/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextRequest } from 'next/server' -import { eq, sql } from 'drizzle-orm' -import { createLogger } from '@/lib/logs/console-logger' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { db } from '@/db' -import * as schema from '@/db/schema' - -const logger = createLogger('MarketplaceStateAPI') - -// Cache for 1 hour -export const revalidate = 3600 - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - - // Fetch marketplace data to get the state - const marketplaceEntry = await db - .select({ - id: schema.marketplace.id, - state: schema.marketplace.state, - }) - .from(schema.marketplace) - .where(eq(schema.marketplace.workflowId, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found for workflow: ${id}`) - return createErrorResponse('Workflow not found in marketplace', 404) - } - - // Increment the view count for this workflow - await db - .update(schema.marketplace) - .set({ - views: sql`${schema.marketplace.views} + 1` - }) - .where(eq(schema.marketplace.workflowId, id)) - - logger.info(`[${requestId}] Retrieved workflow state for marketplace item: ${id}`) - - return createSuccessResponse({ - id: marketplaceEntry.id, - state: marketplaceEntry.state, - }) - } catch (error) { - logger.error( - `[${requestId}] Error getting workflow state for marketplace item: ${(await params).id}`, - error - ) - return createErrorResponse('Failed to get workflow state', 500) - } -} \ No newline at end of file diff --git a/sim/app/api/marketplace/[id]/view/route.ts b/sim/app/api/marketplace/[id]/view/route.ts new file mode 100644 index 0000000000..edfc385174 --- /dev/null +++ b/sim/app/api/marketplace/[id]/view/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server' +import { eq, sql } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { db } from '@/db' +import * as schema 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: schema.marketplace.id, + }) + .from(schema.marketplace) + .where(eq(schema.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(schema.marketplace) + .set({ + views: sql`${schema.marketplace.views} + 1` + }) + .where(eq(schema.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) + } +} \ No newline at end of file diff --git a/sim/app/api/marketplace/featured/route.ts b/sim/app/api/marketplace/featured/route.ts deleted file mode 100644 index bbe349e1f5..0000000000 --- a/sim/app/api/marketplace/featured/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { desc, eq, sql } from 'drizzle-orm' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { marketplace } from '@/db/schema' -import { CATEGORIES } from '@/app/w/marketplace/constants/categories' - -const logger = createLogger('MarketplaceFeaturedAPI') - -// 1 hour cache -export const revalidate = 3600 - -export async function GET(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - // Parse query parameters - const url = new URL(request.url) - const categoryParam = url.searchParams.get('category') - const limitParam = url.searchParams.get('limit') || '6' - const limit = parseInt(limitParam, 10) - - const result: { - popular: any[] - recent: any[] - byCategory: Record - } = { - popular: [], - recent: [], - byCategory: {} - } - - // Get popular items (most stars) - result.popular = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorName: marketplace.authorName, - stars: marketplace.stars, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .orderBy(desc(marketplace.stars), desc(marketplace.views)) - .limit(limit) - - // Get recent items (most recent first) - result.recent = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorName: marketplace.authorName, - stars: marketplace.stars, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .orderBy(desc(marketplace.createdAt)) - .limit(limit) - - // If a specific category is requested - if (categoryParam) { - result.byCategory[categoryParam] = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorName: marketplace.authorName, - stars: marketplace.stars, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.category, categoryParam)) - .orderBy(desc(marketplace.stars), desc(marketplace.views)) - .limit(limit) - } else { - // Get items for each category - // Using Promise.all for parallel fetching to improve performance - await Promise.all( - CATEGORIES.map(async (category) => { - result.byCategory[category.value] = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorName: marketplace.authorName, - stars: marketplace.stars, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.category, category.value)) - .orderBy(desc(marketplace.stars), desc(marketplace.views)) - .limit(limit) - }) - ) - } - - logger.info(`[${requestId}] Fetched featured marketplace items successfully`) - - return NextResponse.json(result) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching marketplace items`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } -} \ No newline at end of file diff --git a/sim/app/api/marketplace/workflows/route.ts b/sim/app/api/marketplace/workflows/route.ts new file mode 100644 index 0000000000..479fef806f --- /dev/null +++ b/sim/app/api/marketplace/workflows/route.ts @@ -0,0 +1,353 @@ +import { NextRequest, NextResponse } from 'next/server' +import { desc, eq, sql } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { db } from '@/db' +import * as schema from '@/db/schema' +import { CATEGORIES } from '@/app/w/marketplace/constants/categories' + +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 = 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: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorId: schema.marketplace.authorId, + authorName: schema.marketplace.authorName, + state: schema.marketplace.state, + stars: schema.marketplace.stars, + views: schema.marketplace.views, + category: schema.marketplace.category, + createdAt: schema.marketplace.createdAt, + updatedAt: schema.marketplace.updatedAt, + }) + .from(schema.marketplace) + .where(eq(schema.marketplace.workflowId, workflowId)) + .limit(1) + .then((rows) => rows[0]); + } else { + // Query without state + marketplaceEntry = await db + .select({ + id: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorId: schema.marketplace.authorId, + authorName: schema.marketplace.authorName, + stars: schema.marketplace.stars, + views: schema.marketplace.views, + category: schema.marketplace.category, + createdAt: schema.marketplace.createdAt, + updatedAt: schema.marketplace.updatedAt, + }) + .from(schema.marketplace) + .where(eq(schema.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: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorId: schema.marketplace.authorId, + authorName: schema.marketplace.authorName, + state: schema.marketplace.state, + stars: schema.marketplace.stars, + views: schema.marketplace.views, + category: schema.marketplace.category, + createdAt: schema.marketplace.createdAt, + updatedAt: schema.marketplace.updatedAt, + }) + .from(schema.marketplace) + .where(eq(schema.marketplace.id, marketplaceId)) + .limit(1) + .then((rows) => rows[0]); + } else { + // Query without state + marketplaceEntry = await db + .select({ + id: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorId: schema.marketplace.authorId, + authorName: schema.marketplace.authorName, + stars: schema.marketplace.stars, + views: schema.marketplace.views, + category: schema.marketplace.category, + createdAt: schema.marketplace.createdAt, + updatedAt: schema.marketplace.updatedAt, + }) + .from(schema.marketplace) + .where(eq(schema.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 + } = { + popular: [], + recent: [], + byCategory: {} + } + + // Define common fields to select + const baseFields = { + id: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorName: schema.marketplace.authorName, + stars: schema.marketplace.stars, + views: schema.marketplace.views, + category: schema.marketplace.category, + createdAt: schema.marketplace.createdAt, + updatedAt: schema.marketplace.updatedAt, + } + + // Add state if requested + const selectFields = includeState + ? { ...baseFields, state: schema.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(schema.marketplace) + .orderBy(desc(schema.marketplace.stars), desc(schema.marketplace.views)) + .limit(limit) + } + + // Get recent items if requested + if (sections.includes('recent')) { + result.recent = await db + .select(selectFields) + .from(schema.marketplace) + .orderBy(desc(schema.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(); + + // 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(schema.marketplace) + .where(eq(schema.marketplace.category, categoryValue)) + .orderBy(desc(schema.marketplace.stars), desc(schema.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 => + 'state' in item ? { + ...item, + workflowState: item.state, + state: undefined + } : 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: schema.marketplace.id, + }) + .from(schema.marketplace) + .where(eq(schema.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(schema.marketplace) + .set({ + views: sql`${schema.marketplace.views} + 1` + }) + .where(eq(schema.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) + } +} \ No newline at end of file diff --git a/sim/app/api/workflows/public/[id]/route.ts b/sim/app/api/workflows/public/[id]/route.ts new file mode 100644 index 0000000000..642ad9b3ad --- /dev/null +++ b/sim/app/api/workflows/public/[id]/route.ts @@ -0,0 +1,68 @@ +import { NextRequest } from 'next/server' +import { eq, sql } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { db } from '@/db' +import * as schema from '@/db/schema' + +const logger = createLogger('PublicWorkflowAPI') + +// Cache response for performance +export const revalidate = 3600 + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const { id } = await params + + // First, check if the workflow exists and is published to the marketplace + const marketplaceEntry = await db + .select({ + id: schema.marketplace.id, + workflowId: schema.marketplace.workflowId, + state: schema.marketplace.state, + name: schema.marketplace.name, + description: schema.marketplace.description, + authorId: schema.marketplace.authorId, + authorName: schema.marketplace.authorName, + }) + .from(schema.marketplace) + .where(eq(schema.marketplace.workflowId, id)) + .limit(1) + .then((rows) => rows[0]) + + if (!marketplaceEntry) { + // Check if workflow exists but is not in marketplace + const workflowExists = await db + .select({ id: schema.workflow.id }) + .from(schema.workflow) + .where(eq(schema.workflow.id, id)) + .limit(1) + .then((rows) => rows.length > 0) + + if (!workflowExists) { + logger.warn(`[${requestId}] Workflow not found: ${id}`) + return createErrorResponse('Workflow not found', 404) + } + + logger.warn(`[${requestId}] Workflow exists but is not published: ${id}`) + return createErrorResponse('Workflow is not published', 403) + } + + logger.info(`[${requestId}] Retrieved public workflow: ${id}`) + + return createSuccessResponse({ + id: marketplaceEntry.workflowId, + name: marketplaceEntry.name, + description: marketplaceEntry.description, + authorId: marketplaceEntry.authorId, + authorName: marketplaceEntry.authorName, + state: marketplaceEntry.state, + isPublic: true, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting public workflow: ${(await params).id}`, error) + return createErrorResponse('Failed to get public workflow', 500) + } +} \ No newline at end of file diff --git a/sim/app/w/[id]/workflow.tsx b/sim/app/w/[id]/workflow.tsx index c1fb2c1035..c099087dfd 100644 --- a/sim/app/w/[id]/workflow.tsx +++ b/sim/app/w/[id]/workflow.tsx @@ -43,6 +43,9 @@ function WorkflowContent() { // State const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [isInitialized, setIsInitialized] = useState(false) + const [isPublicWorkflow, setIsPublicWorkflow] = useState(false) + const [publicWorkflowData, setPublicWorkflowData] = useState(null) + const [loadingPublicWorkflow, setLoadingPublicWorkflow] = useState(false) // Hooks const params = useParams() @@ -51,8 +54,16 @@ function WorkflowContent() { // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } = - useWorkflowStore() + const { + blocks, + edges, + loops, + addBlock, + updateBlockPosition, + addEdge, + removeEdge, + initializeWorkflow, + } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() const { resetLoaded: resetVariablesLoaded } = useVariablesStore() @@ -151,9 +162,47 @@ function WorkflowContent() { return } + // Check if the workflow is in the user's registry if (!workflows[currentId]) { - router.replace(`/w/${workflowIds[0]}`) - return + // If not in registry, try to load it as a public workflow + setLoadingPublicWorkflow(true) + try { + const response = await fetch(`/api/workflows/public/${currentId}`) + + if (response.ok) { + // Workflow exists and is public + const data = await response.json() + setPublicWorkflowData(data.data) + setIsPublicWorkflow(true) + + // Initialize the workflow store with the public workflow state + if (data.data?.state) { + const { blocks, edges, loops } = data.data.state + initializeWorkflow(blocks || {}, edges || [], loops || {}) + + // Initialize subblock store with workflow values + if (blocks) { + useSubBlockStore.getState().initializeFromWorkflow(currentId, blocks) + } + } + setLoadingPublicWorkflow(false) + return + } else if (response.status === 403) { + // Workflow exists but is not public, redirect to first workflow + router.replace(`/w/${workflowIds[0]}`) + return + } else { + // Workflow doesn't exist, redirect to first workflow + router.replace(`/w/${workflowIds[0]}`) + return + } + } catch (error) { + console.error('Error loading public workflow:', error) + router.replace(`/w/${workflowIds[0]}`) + return + } finally { + setLoadingPublicWorkflow(false) + } } // Import the isActivelyLoadingFromDB function to check sync status @@ -190,6 +239,7 @@ function WorkflowContent() { isInitialized, markAllAsRead, resetVariablesLoaded, + initializeWorkflow, ]) // Transform blocks and loops into ReactFlow nodes diff --git a/sim/app/w/marketplace/components/workflow-card.tsx b/sim/app/w/marketplace/components/workflow-card.tsx index 3ac8ef08b2..264493437e 100644 --- a/sim/app/w/marketplace/components/workflow-card.tsx +++ b/sim/app/w/marketplace/components/workflow-card.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import Link from 'next/link' import { Eye, Star } from 'lucide-react' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Workflow } from '../marketplace' @@ -21,78 +22,110 @@ interface WorkflowCardProps { /** * WorkflowCard component - Displays a workflow in a card format * Shows either a workflow preview, thumbnail image, or fallback text - * Loads workflow state on hover if not already loaded + * State is now pre-loaded in most cases, fallback to load on hover if needed */ -export function WorkflowCard({ workflow, index, onHover }: WorkflowCardProps) { - const [isHovered, setIsHovered] = useState(false) +export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) { + const [isPreviewReady, setIsPreviewReady] = useState(!!workflow.workflowState) + + // 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 = () => { - setIsHovered(true) if (onHover && !workflow.workflowState) { onHover(workflow.id) } } + /** + * Handle workflow card click - track views + */ + const handleClick = async () => { + try { + await fetch(`/api/marketplace/workflows`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: workflow.id }), + }) + } catch (error) { + console.error('Failed to track workflow view:', error) + } + } + + // Extract the actual workflow ID from workflow + const workflowUrl = workflow.workflowUrl || `/w/${workflow.id}` + return ( - setIsHovered(false)} + - {/* Workflow preview/thumbnail area */} -
- {workflow.workflowState ? ( - // Show interactive workflow preview if state is available -
-
- + + {/* Workflow preview/thumbnail area */} +
+ {isPreviewReady && workflow.workflowState ? ( + // Show interactive workflow preview if state is available +
+
+ +
-
- ) : workflow.thumbnail ? ( - // Show static thumbnail image if available -
- ) : ( - // Fallback to text if no preview or thumbnail is available -
- {workflow.name} -
- )} -
-
- {/* Workflow title */} - -

{workflow.name}

-
- {/* Workflow description */} - -

{workflow.description}

-
- {/* Footer with author and stats */} - -
by {workflow.author}
-
-
- - {workflow.stars} + ) : workflow.thumbnail ? ( + // Show static thumbnail image if available +
+ ) : ( + // Fallback to text if no preview or thumbnail is available +
+ {workflow.name}
-
- - {workflow.views} + )} +
+
+ {/* Workflow title */} + +

{workflow.name}

+
+ {/* Workflow description */} + +

{workflow.description}

+
+ {/* Footer with author and stats */} + +
by {workflow.author}
+
+
+ + {workflow.stars} +
+
+ + {workflow.views} +
-
- -
- + +
+ + ) } diff --git a/sim/app/w/marketplace/marketplace.tsx b/sim/app/w/marketplace/marketplace.tsx index 52512b6d17..d30a6dff9d 100644 --- a/sim/app/w/marketplace/marketplace.tsx +++ b/sim/app/w/marketplace/marketplace.tsx @@ -11,6 +11,7 @@ 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 } from './constants/categories' // Types export interface Workflow { @@ -22,7 +23,7 @@ export interface Workflow { views: number tags: string[] thumbnail?: string - workflowUrl?: string + workflowUrl: string workflowState?: { blocks: Record edges: Array<{ @@ -67,6 +68,9 @@ export interface MarketplaceData { byCategory: Record } +// 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) @@ -77,10 +81,13 @@ export default function Marketplace() { byCategory: {}, }) const [activeSection, setActiveSection] = useState(null) + const [loadedSections, setLoadedSections] = useState>(new Set(['popular', 'recent'])) + const [visibleSections, setVisibleSections] = useState>(new Set(['popular'])) // Create refs for each section const sectionRefs = useRef>({}) const contentRef = useRef(null) + const initialFetchCompleted = useRef(false) // Convert marketplace data to the format expected by components const workflowData = useMemo(() => { @@ -94,6 +101,7 @@ export default function Marketplace() { views: item.views, tags: [item.category], workflowState: item.workflowState, + workflowUrl: `/w/${item.workflowId}`, })), recent: marketplaceData.recent.map((item) => ({ id: item.id, @@ -104,6 +112,7 @@ export default function Marketplace() { views: item.views, tags: [item.category], workflowState: item.workflowState, + workflowUrl: `/w/${item.workflowId}`, })), } @@ -119,6 +128,7 @@ export default function Marketplace() { views: item.views, tags: [item.category], workflowState: item.workflowState, + workflowUrl: `/w/${item.workflowId}`, })) } }) @@ -126,22 +136,46 @@ export default function Marketplace() { return result }, [marketplaceData]) - // Fetch workflows on component mount + // Fetch workflows on component mount - improved to include state initially useEffect(() => { - const fetchWorkflows = async () => { + const fetchInitialData = async () => { try { setLoading(true) - const response = await fetch('/api/marketplace/featured') + // 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() - setMarketplaceData(data) - // Set initial active section to first category + // 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) { @@ -151,11 +185,61 @@ export default function Marketplace() { } } - fetchWorkflows() + fetchInitialData() }, []) - // Function to fetch workflow state for a specific workflow - const fetchWorkflowState = async (workflowId: string) => { + // 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 @@ -176,35 +260,44 @@ export default function Marketplace() { } } - // If we have the workflow and it doesn't already have state + // If we have the workflow but it doesn't have state, fetch it if (foundWorkflow && !foundWorkflow.workflowState) { - // In a real implementation, fetch the workflow state from the server - // For now, we'll just use a placeholder - const response = await fetch(`/api/marketplace/${foundWorkflow.workflowId}/state`, { - method: 'GET', - }) + const response = await fetch( + `/api/marketplace/workflows?marketplaceId=${workflowId}&includeState=true`, + { + method: 'GET', + } + ) if (response.ok) { - const stateData = await response.json() + 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 = updatedData.popular.map((w) => - w.id === workflowId ? { ...w, workflowState: stateData.state } : w - ) + updatedData.popular = updateWorkflowInSection(updatedData.popular) // Update in recent - updatedData.recent = updatedData.recent.map((w) => - w.id === workflowId ? { ...w, workflowState: stateData.state } : w - ) + updatedData.recent = updateWorkflowInSection(updatedData.recent) // Update in categories Object.keys(updatedData.byCategory).forEach((category) => { - updatedData.byCategory[category] = updatedData.byCategory[category].map((w) => - w.id === workflowId ? { ...w, workflowState: stateData.state } : w + updatedData.byCategory[category] = updateWorkflowInSection( + updatedData.byCategory[category] ) }) @@ -213,7 +306,7 @@ export default function Marketplace() { } } } catch (error) { - console.error(`Error fetching workflow state for ${workflowId}:`, error) + console.error(`Error ensuring workflow state for ${workflowId}:`, error) } } @@ -243,9 +336,40 @@ export default function Marketplace() { 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', @@ -253,8 +377,10 @@ export default function Marketplace() { } } - // Setup intersection observer to track active section + // 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( @@ -262,31 +388,56 @@ export default function Marketplace() { ) } - // Store current section positions for better tracking - const sectionPositions: Record = {} + // Create intersection observer to detect when sections enter viewport + const observeSections = () => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const sectionId = entry.target.id - // Initial calculation of section positions - const calculateSectionPositions = () => { + // 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) { - sectionPositions[id] = ref.getBoundingClientRect() + observer.observe(ref) } }) + + return observer } - // Debounce function to limit rapid position calculations - const debounce = (fn: Function, delay: number) => { - let timer: NodeJS.Timeout - return function (...args: any[]) { - clearTimeout(timer) - timer = setTimeout(() => fn(...args), delay) - } - } - - // Calculate positions initially and on resize - calculateSectionPositions() - const debouncedCalculatePositions = debounce(calculateSectionPositions, 100) - window.addEventListener('resize', debouncedCalculatePositions) + const observer = observeSections() // Use a single source of truth for determining the active section const determineActiveSection = () => { @@ -347,10 +498,10 @@ export default function Marketplace() { contentElement?.addEventListener('scroll', handleScroll, { passive: true }) return () => { - window.removeEventListener('resize', debouncedCalculatePositions) + observer.disconnect() contentElement?.removeEventListener('scroll', handleScroll) } - }, [loading, filteredWorkflows]) + }, [initialFetchCompleted.current, loading, filteredWorkflows, loadedSections]) return (
@@ -388,7 +539,7 @@ export default function Marketplace() { {/* Render workflow sections */} {!loading && ( <> - {Object.entries(filteredWorkflows).map( + {sortedFilteredWorkflows.map( ([category, workflows]) => workflows.length > 0 && (
))}
@@ -415,7 +566,7 @@ export default function Marketplace() { ) )} - {Object.keys(filteredWorkflows).length === 0 && !loading && ( + {sortedFilteredWorkflows.length === 0 && !loading && (

No workflows found matching your search.

diff --git a/sim/blocks/blocks/evaluator.ts b/sim/blocks/blocks/evaluator.ts index 9ec6eb222c..3947cde9ed 100644 --- a/sim/blocks/blocks/evaluator.ts +++ b/sim/blocks/blocks/evaluator.ts @@ -124,7 +124,7 @@ export const EvaluatorBlock: BlockConfig = { description: 'Evaluate content', longDescription: 'Assess content quality using customizable evaluation metrics and scoring criteria. Create objective evaluation frameworks with numeric scoring to measure performance across multiple dimensions.', - category: 'blocks', + category: 'tools', bgColor: '#2FA1FF', icon: ChartBarIcon, subBlocks: [ diff --git a/sim/stores/workflows/workflow/store.ts b/sim/stores/workflows/workflow/store.ts index 487f27c5df..fe0457cae3 100644 --- a/sim/stores/workflows/workflow/store.ts +++ b/sim/stores/workflows/workflow/store.ts @@ -306,6 +306,23 @@ export const useWorkflowStore = create()( } }, + // Initialize workflow from external state (used for public workflows) + initializeWorkflow: (blocks: any, edges: any, loops: any) => { + const newState = { + blocks: blocks || {}, + edges: edges || [], + loops: loops || {}, + lastSaved: Date.now(), + isDeployed: false, + deployedAt: undefined, + isPublished: true, + } + + set(newState) + // Don't push to history since this is an initial load + // Don't sync to avoid overwriting the original workflow + }, + toggleBlockEnabled: (id: string) => { const newState = { blocks: { diff --git a/sim/stores/workflows/workflow/types.ts b/sim/stores/workflows/workflow/types.ts index 88adc44905..8d5b3793d2 100644 --- a/sim/stores/workflows/workflow/types.ts +++ b/sim/stores/workflows/workflow/types.ts @@ -62,6 +62,7 @@ export interface WorkflowActions { triggerUpdate: () => void setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void setPublishStatus: (isPublished: boolean) => void + initializeWorkflow: (blocks: any, edges: any, loops: any) => void } export type WorkflowStore = WorkflowState & WorkflowActions