improvement(marketplace): loading and viewing

This commit is contained in:
Emir Karabeg
2025-03-25 14:28:15 -07:00
parent 1ab98ca58b
commit d5e1560b47
12 changed files with 840 additions and 413 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, any[]>
} = {
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 })
}
}

View File

@@ -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<string, any[]>
} = {
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<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(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)
}
}

View File

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

View File

@@ -43,6 +43,9 @@ function WorkflowContent() {
// State
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
const [isPublicWorkflow, setIsPublicWorkflow] = useState(false)
const [publicWorkflowData, setPublicWorkflowData] = useState<any>(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

View File

@@ -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 (
<Card
className="overflow-hidden transition-all hover:shadow-md flex flex-col h-full"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
<Link
href={workflowUrl}
className="block"
aria-label={`View ${workflow.name} workflow`}
onClick={handleClick}
>
{/* Workflow preview/thumbnail area */}
<div className="h-40 relative overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900">
{workflow.workflowState ? (
// Show interactive workflow preview if state is available
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full h-full transform-gpu scale-[0.8]">
<WorkflowPreview workflowState={workflow.workflowState} />
<Card
className="overflow-hidden transition-all hover:shadow-md flex flex-col h-full"
onMouseEnter={handleMouseEnter}
>
{/* Workflow preview/thumbnail area */}
<div className="h-40 relative overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900">
{isPreviewReady && workflow.workflowState ? (
// Show interactive workflow preview if state is available
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full h-full transform-gpu scale-[0.8]">
<WorkflowPreview workflowState={workflow.workflowState} />
</div>
</div>
</div>
) : workflow.thumbnail ? (
// Show static thumbnail image if available
<div
className="h-full w-full bg-cover bg-center"
style={{
backgroundImage: `url(${workflow.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center top',
}}
/>
) : (
// Fallback to text if no preview or thumbnail is available
<div className="h-full w-full flex items-center justify-center">
<span className="text-muted-foreground font-medium text-lg">{workflow.name}</span>
</div>
)}
</div>
<div className="flex flex-col flex-grow">
{/* Workflow title */}
<CardHeader className="p-4 pb-2">
<h3 className="font-medium text-sm">{workflow.name}</h3>
</CardHeader>
{/* Workflow description */}
<CardContent className="p-4 pt-0 pb-2 flex-grow flex flex-col">
<p className="text-xs text-muted-foreground line-clamp-2">{workflow.description}</p>
</CardContent>
{/* Footer with author and stats */}
<CardFooter className="p-4 pt-2 mt-auto flex justify-between items-center">
<div className="text-xs text-muted-foreground">by {workflow.author}</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-1">
<Star className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{workflow.stars}</span>
) : workflow.thumbnail ? (
// Show static thumbnail image if available
<div
className="h-full w-full bg-cover bg-center"
style={{
backgroundImage: `url(${workflow.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center top',
}}
/>
) : (
// Fallback to text if no preview or thumbnail is available
<div className="h-full w-full flex items-center justify-center">
<span className="text-muted-foreground font-medium text-lg">{workflow.name}</span>
</div>
<div className="flex items-center space-x-1">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{workflow.views}</span>
)}
</div>
<div className="flex flex-col flex-grow">
{/* Workflow title */}
<CardHeader className="p-4 pb-2">
<h3 className="font-medium text-sm">{workflow.name}</h3>
</CardHeader>
{/* Workflow description */}
<CardContent className="p-4 pt-0 pb-2 flex-grow flex flex-col">
<p className="text-xs text-muted-foreground line-clamp-2">{workflow.description}</p>
</CardContent>
{/* Footer with author and stats */}
<CardFooter className="p-4 pt-2 mt-auto flex justify-between items-center">
<div className="text-xs text-muted-foreground">by {workflow.author}</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-1">
<Star className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{workflow.stars}</span>
</div>
<div className="flex items-center space-x-1">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{workflow.views}</span>
</div>
</div>
</div>
</CardFooter>
</div>
</Card>
</CardFooter>
</div>
</Card>
</Link>
)
}

View File

@@ -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<string, any>
edges: Array<{
@@ -67,6 +68,9 @@ export interface MarketplaceData {
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)
@@ -77,10 +81,13 @@ export default function Marketplace() {
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(() => {
@@ -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&section=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<string, DOMRect> = {}
// 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 (
<div className="flex flex-col h-[100vh]">
@@ -388,7 +539,7 @@ export default function Marketplace() {
{/* Render workflow sections */}
{!loading && (
<>
{Object.entries(filteredWorkflows).map(
{sortedFilteredWorkflows.map(
([category, workflows]) =>
workflows.length > 0 && (
<Section
@@ -407,7 +558,7 @@ export default function Marketplace() {
key={workflow.id}
workflow={workflow}
index={index}
onHover={fetchWorkflowState}
onHover={ensureWorkflowState}
/>
))}
</div>
@@ -415,7 +566,7 @@ export default function Marketplace() {
)
)}
{Object.keys(filteredWorkflows).length === 0 && !loading && (
{sortedFilteredWorkflows.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-64">
<AlertCircle className="h-8 w-8 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No workflows found matching your search.</p>

View File

@@ -124,7 +124,7 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
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: [

View File

@@ -306,6 +306,23 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
}
},
// 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: {

View File

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