improvement: marketplace and sidebar

This commit is contained in:
Emir Karabeg
2025-04-02 20:05:18 -07:00
parent e14c705640
commit c7ad9d5dd8
14 changed files with 1524 additions and 132 deletions
+18 -1
View File
@@ -8,6 +8,12 @@ import { workflow } from '@/db/schema'
const logger = createLogger('WorkflowAPI')
// Define marketplace data schema
const MarketplaceDataSchema = z.object({
id: z.string(),
status: z.enum(['owner', 'temp', 'star'])
}).nullable().optional()
// Schema for workflow data
const WorkflowStateSchema = z.object({
blocks: z.record(z.any()),
@@ -20,6 +26,7 @@ const WorkflowStateSchema = z.object({
.optional()
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
isPublished: z.boolean().optional(),
marketplaceData: MarketplaceDataSchema
})
const WorkflowSchema = z.object({
@@ -28,6 +35,7 @@ const WorkflowSchema = z.object({
description: z.string().optional(),
color: z.string().optional(),
state: WorkflowStateSchema,
marketplaceData: MarketplaceDataSchema
})
const SyncPayloadSchema = z.object({
@@ -114,6 +122,12 @@ export async function POST(req: NextRequest) {
processedIds.add(id)
const dbWorkflow = dbWorkflowMap.get(id)
// Handle legacy published workflows migration
// If client workflow has isPublished but no marketplaceData, create marketplaceData with owner status
if (clientWorkflow.state.isPublished && !clientWorkflow.marketplaceData) {
clientWorkflow.marketplaceData = { id: clientWorkflow.id, status: 'owner' }
}
if (!dbWorkflow) {
// New workflow - create
operations.push(
@@ -124,6 +138,7 @@ export async function POST(req: NextRequest) {
description: clientWorkflow.description,
color: clientWorkflow.color,
state: clientWorkflow.state,
marketplaceData: clientWorkflow.marketplaceData || null,
lastSynced: now,
createdAt: now,
updatedAt: now,
@@ -135,7 +150,8 @@ export async function POST(req: NextRequest) {
JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state) ||
dbWorkflow.name !== clientWorkflow.name ||
dbWorkflow.description !== clientWorkflow.description ||
dbWorkflow.color !== clientWorkflow.color
dbWorkflow.color !== clientWorkflow.color ||
JSON.stringify(dbWorkflow.marketplaceData) !== JSON.stringify(clientWorkflow.marketplaceData)
if (needsUpdate) {
operations.push(
@@ -146,6 +162,7 @@ export async function POST(req: NextRequest) {
description: clientWorkflow.description,
color: clientWorkflow.color,
state: clientWorkflow.state,
marketplaceData: clientWorkflow.marketplaceData || null,
lastSynced: now,
updatedAt: now,
})
@@ -53,7 +53,6 @@ import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import {
CATEGORIES,
getCategoryColor,
@@ -144,8 +143,24 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
const [marketplaceInfo, setMarketplaceInfo] = useState<MarketplaceInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const { addNotification } = useNotificationStore()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const { isPublished, setPublishStatus } = useWorkflowStore()
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>({
@@ -161,7 +176,7 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
// Fetch marketplace information when the modal opens and the workflow is published
useEffect(() => {
async function fetchMarketplaceInfo() {
if (!open || !activeWorkflowId || !isPublished) {
if (!open || !activeWorkflowId || !isPublished()) {
setMarketplaceInfo(null)
return
}
@@ -185,16 +200,16 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
}
fetchMarketplaceInfo()
}, [open, activeWorkflowId, isPublished, addNotification])
}, [open, activeWorkflowId, addNotification])
// Update form values when the active workflow changes or modal opens
useEffect(() => {
if (open && activeWorkflowId && workflows[activeWorkflowId] && !isPublished) {
if (open && activeWorkflowId && workflows[activeWorkflowId] && !isPublished()) {
const workflow = workflows[activeWorkflowId]
form.setValue('name', workflow.name)
form.setValue('description', workflow.description || '')
}
}, [open, activeWorkflowId, workflows, form, isPublished])
}, [open, activeWorkflowId, workflows, form])
// Listen for the custom event to open the marketplace modal
useEffect(() => {
@@ -255,8 +270,10 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
throw new Error(errorData.error || 'Failed to publish workflow')
}
// Update the publish status with the current date
setPublishStatus(true)
// Update the marketplace data in the workflow registry
updateWorkflow(activeWorkflowId, {
marketplaceData: { id: activeWorkflowId, status: 'owner' },
})
// Add a marketplace notification with detailed information
addNotification(
@@ -293,8 +310,10 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
throw new Error(errorData.error || 'Failed to unpublish workflow')
}
// Update the publish status
setPublishStatus(false)
// Remove the marketplace data from the workflow registry
updateWorkflow(activeWorkflowId, {
marketplaceData: null,
})
// Add a notification
addNotification(
@@ -402,26 +421,20 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="destructive"
onClick={handleUnpublish}
disabled={isUnpublishing}
className="gap-2"
>
<Trash className="h-4 w-4" />
{isUnpublishing ? (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-background"></span>
Unpublishing...
</span>
) : (
'Unpublish'
)}
</Button>
</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 ? 'Unpublishing...' : 'Unpublish'}
</Button>
</div>
)}
</div>
)
}
@@ -551,7 +564,7 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">
{isPublished ? 'Marketplace Information' : 'Publish to Marketplace'}
{isPublished() ? 'Marketplace Information' : 'Publish to Marketplace'}
</DialogTitle>
<Button
variant="ghost"
@@ -566,7 +579,7 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
</DialogHeader>
<div className="pt-4 px-6 pb-6 overflow-y-auto">
{isPublished ? renderMarketplaceInfo() : renderPublishForm()}
{isPublished() ? renderMarketplaceInfo() : renderPublishForm()}
</div>
</DialogContent>
</Dialog>
@@ -65,15 +65,8 @@ export function ControlBar() {
// Store hooks
const { notifications, getWorkflowNotifications, addNotification, showNotification } =
useNotificationStore()
const {
history,
revertToHistoryState,
lastSaved,
isDeployed,
isPublished,
setDeploymentStatus,
setPublishStatus,
} = useWorkflowStore()
const { history, revertToHistoryState, lastSaved, isDeployed, setDeploymentStatus } =
useWorkflowStore()
const { workflows, updateWorkflow, activeWorkflowId, removeWorkflow } = useWorkflowRegistry()
const { isExecuting, handleRunWorkflow } = useWorkflowExecution()
@@ -112,6 +105,24 @@ export function ControlBar() {
? getWorkflowNotifications(activeWorkflowId)
: notifications // Show all if no workflow is active
// Get the marketplace data from the workflow registry if available
const getMarketplaceData = () => {
if (!activeWorkflowId || !workflows[activeWorkflowId]) return null
return workflows[activeWorkflowId].marketplaceData
}
// Check if the current workflow is published to marketplace
const isPublishedToMarketplace = () => {
const marketplaceData = getMarketplaceData()
return !!marketplaceData
}
// Check if the current user is the owner of the published workflow
const isWorkflowOwner = () => {
const marketplaceData = getMarketplaceData()
return marketplaceData?.status === 'owner'
}
// Client-side only rendering for the timestamp
useEffect(() => {
setMounted(true)
@@ -149,14 +160,13 @@ export function ControlBar() {
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
setPublishStatus(data.isPublished)
}
} catch (error) {
logger.error('Failed to check workflow status:', { error })
}
}
checkStatus()
}, [activeWorkflowId, setDeploymentStatus, setPublishStatus])
}, [activeWorkflowId, setDeploymentStatus])
/**
* Workflow name handlers
@@ -364,6 +374,7 @@ export function ControlBar() {
if (!activeWorkflowId) return
// If already published, show marketplace modal with info instead of notifications
const isPublished = isPublishedToMarketplace()
if (isPublished) {
setIsMarketplaceModalOpen(true)
return
@@ -488,7 +499,10 @@ export function ControlBar() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteWorkflow} className="bg-red-600 hover:bg-red-700">
<AlertDialogAction
onClick={handleDeleteWorkflow}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
@@ -636,33 +650,37 @@ export function ControlBar() {
/**
* Render publish button
*/
const renderPublishButton = () => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handlePublishWorkflow}
disabled={isPublishing}
className={cn('hover:text-[#802FFF]', isPublished && 'text-[#802FFF]')}
>
{isPublishing ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Store className="h-5 w-5" />
)}
<span className="sr-only">Publish to Marketplace</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isPublishing
? 'Publishing...'
: isPublished
? 'Published to Marketplace'
: 'Publish to Marketplace'}
</TooltipContent>
</Tooltip>
)
const renderPublishButton = () => {
const isPublished = isPublishedToMarketplace()
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handlePublishWorkflow}
disabled={isPublishing}
className={cn('hover:text-[#802FFF]', isPublished && 'text-[#802FFF]')}
>
{isPublishing ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Store className="h-5 w-5" />
)}
<span className="sr-only">Publish to Marketplace</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isPublishing
? 'Publishing...'
: isPublished
? 'Published to Marketplace'
: 'Publish to Marketplace'}
</TooltipContent>
</Tooltip>
)
}
/**
* Render debug mode controls
@@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { Folder } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { NavItem } from '../nav-item/nav-item'
interface FolderSectionProps {
title: string
workflows: WorkflowMetadata[]
defaultOpen?: boolean
}
export function FolderSection({ title, workflows, defaultOpen = true }: FolderSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen)
const pathname = usePathname()
// Auto-expand the folder if the current path is one of the workflows inside it
useEffect(() => {
const isCurrentPathInThisFolder = workflows.some((workflow) => pathname === `/w/${workflow.id}`)
if (isCurrentPathInThisFolder) {
setIsOpen(true)
}
}, [pathname, workflows])
if (workflows.length === 0) {
return null
}
return (
<div className="flex flex-col items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
className={`flex !h-9 !w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground md:h-8 md:w-8 ${
isOpen ? 'bg-accent' : ''
}`}
>
<Folder className="!h-5 !w-5" />
<span className="sr-only">{title}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
{isOpen && (
<div className="mt-3 flex flex-col items-center gap-3">
{workflows.map((workflow) => (
<NavItem key={workflow.id} href={`/w/${workflow.id}`} label={workflow.name}>
<div
className="h-4 w-4 rounded-full"
style={{
backgroundColor:
workflow.color ||
(workflow.marketplaceData?.status === 'temp' ? '#808080' : '#3972F6'),
}}
/>
</NavItem>
))}
</div>
)}
</div>
)
}
+39 -38
View File
@@ -4,11 +4,13 @@ import { useMemo, useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import clsx from 'clsx'
import { Anvil, HelpCircle, Plus, ScrollText, Settings, Store } from 'lucide-react'
import { HelpCircle, Plus, ScrollText, Settings, Store } from 'lucide-react'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { FolderSection } from './components/folder-section/folder-section'
import { HelpModal } from './components/help-modal/help-modal'
import { NavItem } from './components/nav-item/nav-item'
import { SettingsModal } from './components/settings-modal/settings-modal'
@@ -20,11 +22,21 @@ export function Sidebar() {
const [showSettings, setShowSettings] = useState(false)
const [showHelp, setShowHelp] = useState(false)
// Sort workflows by lastModified date (which corresponds to createdAt for new workflows)
// Newest workflows at the top (ascending order by date)
const sortedWorkflows = useMemo(() => {
return Object.values(workflows).sort((a, b) => {
// Ensure we're comparing dates properly by converting to timestamps
// Separate regular workflows from temporary marketplace workflows
const { regularWorkflows, tempWorkflows } = useMemo(() => {
const regular: WorkflowMetadata[] = []
const temp: WorkflowMetadata[] = []
Object.values(workflows).forEach((workflow) => {
if (workflow.marketplaceData?.status === 'temp') {
temp.push(workflow)
} else {
regular.push(workflow)
}
})
// Sort regular workflows by last modified date (newest first)
regular.sort((a, b) => {
const dateA =
a.lastModified instanceof Date
? a.lastModified.getTime()
@@ -33,8 +45,23 @@ export function Sidebar() {
b.lastModified instanceof Date
? b.lastModified.getTime()
: new Date(b.lastModified).getTime()
return dateB - dateA // Ascending order (oldest last, newest first)
return dateB - dateA
})
// Sort temp workflows by last modified date (newest first)
temp.sort((a, b) => {
const dateA =
a.lastModified instanceof Date
? a.lastModified.getTime()
: new Date(a.lastModified).getTime()
const dateB =
b.lastModified instanceof Date
? b.lastModified.getTime()
: new Date(b.lastModified).getTime()
return dateB - dateA
})
return { regularWorkflows: regular, tempWorkflows: temp }
}, [workflows])
// Create workflow
@@ -88,14 +115,11 @@ export function Sidebar() {
{/* Scrollable workflows section */}
<nav className="flex-1 overflow-y-auto px-2 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<div className="flex flex-col items-center gap-4">
{sortedWorkflows.map((workflow) => (
<NavItem key={workflow.id} href={`/w/${workflow.id}`} label={workflow.name}>
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: workflow.color || '#3972F6' }}
/>
</NavItem>
))}
{/* My Workflows folder */}
<FolderSection title="My Workflows" workflows={regularWorkflows} defaultOpen={true} />
{/* Marketplace Workflows folder */}
<FolderSection title="Marketplace" workflows={tempWorkflows} defaultOpen={false} />
</div>
</nav>
@@ -123,29 +147,6 @@ export function Sidebar() {
<TooltipContent side="right">Marketplace</TooltipContent>
</Tooltip>
{/* Agents */}
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className={clsx(
'flex !h-9 !w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground md:h-8 md:w-8',
{
'bg-accent': pathname === '/w/agents',
}
)}
>
<Link href="/w/agents">
<Anvil className="!h-5 !w-5" />
<span className="sr-only">Agents</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">Agents</TooltipContent>
</Tooltip> */}
{/* Logs */}
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1 @@
ALTER TABLE "workflow" ALTER COLUMN "marketplace_data" DROP DEFAULT;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -176,6 +176,13 @@
"when": 1743642824678,
"tag": "0024_next_whizzer",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1743643010845,
"tag": "0025_curved_jubilee",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -77,7 +77,7 @@ export const workflow = pgTable('workflow', {
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
marketplaceData: json('marketplace_data').default(null), // Format: { id: string, status: 'owner' | 'temp' | 'star' }
marketplaceData: json('marketplace_data'), // Format: { id: string, status: 'owner' | 'temp' | 'star' }
// These columns are kept for backward compatibility during migration
// and should be marked as deprecated
+2
View File
@@ -52,6 +52,7 @@ export function getWorkflowWithValues(workflowId: string) {
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
marketplaceData: metadata.marketplaceData || null,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
@@ -114,6 +115,7 @@ export function getAllWorkflowsWithValues() {
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
marketplaceData: metadata.marketplaceData || null,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
+4 -4
View File
@@ -142,8 +142,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
lastModified: new Date(),
description: options.description || 'New workflow',
color: options.marketplaceId ? '#808080' : getNextWorkflowColor(workflows), // Gray for marketplace imports
marketplaceStatus: options.marketplaceId ? 'temp' : undefined,
marketplaceId: options.marketplaceId,
marketplaceData: options.marketplaceId
? { id: options.marketplaceId, status: 'temp' as const }
: undefined,
}
let initialState;
@@ -350,8 +351,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
lastModified: new Date(),
description: metadata.description || 'Imported from marketplace',
color: metadata.color || getNextWorkflowColor(workflows),
marketplaceStatus: 'temp', // Initial status is temp, user can star it later
marketplaceId: marketplaceId, // Reference to original marketplace workflow
marketplaceData: { id: marketplaceId, status: 'temp' as const },
}
// Prepare workflow state based on the marketplace workflow state
+6 -2
View File
@@ -1,11 +1,15 @@
export interface MarketplaceData {
id: string
status: 'owner' | 'temp' | 'star'
}
export interface WorkflowMetadata {
id: string
name: string
lastModified: Date
description?: string
color: string
marketplaceStatus?: 'temp' | 'star' | null
marketplaceId?: string
marketplaceData?: MarketplaceData | null
}
export interface WorkflowRegistryState {
-12
View File
@@ -297,7 +297,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
isPublished: currentState.isPublished,
lastSaved: Date.now(),
})
@@ -621,17 +620,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
get().updateLastSaved()
workflowSync.sync()
},
setPublishStatus: (isPublished: boolean) => {
const newState = {
...get(),
isPublished,
}
set(newState)
get().updateLastSaved()
workflowSync.sync()
},
})),
{ name: 'workflow-store' }
)
-2
View File
@@ -41,7 +41,6 @@ export interface WorkflowState {
lastUpdate?: number
isDeployed?: boolean
deployedAt?: Date
isPublished?: boolean
}
export interface WorkflowActions {
@@ -63,7 +62,6 @@ export interface WorkflowActions {
updateLoopForEachItems: (loopId: string, items: string) => void
triggerUpdate: () => void
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
setPublishStatus: (isPublished: boolean) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions