mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(context-menu): added context menu to dead sidebar space and usage indicator (#2841)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface UsageIndicatorContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Menu items configuration based on plan and permissions
|
||||
*/
|
||||
menuItems: UsageMenuItems
|
||||
}
|
||||
|
||||
interface UsageMenuItems {
|
||||
/**
|
||||
* Show "Set usage limit" option
|
||||
*/
|
||||
showSetLimit: boolean
|
||||
/**
|
||||
* Show "Upgrade to Pro" option (free users)
|
||||
*/
|
||||
showUpgradeToPro: boolean
|
||||
/**
|
||||
* Show "Upgrade to Team" option (free or pro users)
|
||||
*/
|
||||
showUpgradeToTeam: boolean
|
||||
/**
|
||||
* Show "Manage seats" option (team admins)
|
||||
*/
|
||||
showManageSeats: boolean
|
||||
/**
|
||||
* Show "Upgrade to Enterprise" option
|
||||
*/
|
||||
showUpgradeToEnterprise: boolean
|
||||
/**
|
||||
* Show "Contact support" option (enterprise users)
|
||||
*/
|
||||
showContactSupport: boolean
|
||||
/**
|
||||
* Callbacks
|
||||
*/
|
||||
onSetLimit?: () => void
|
||||
onUpgradeToPro?: () => void
|
||||
onUpgradeToTeam?: () => void
|
||||
onManageSeats?: () => void
|
||||
onUpgradeToEnterprise?: () => void
|
||||
onContactSupport?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for usage indicator.
|
||||
* Displays plan-appropriate options in a popover at the right-click position.
|
||||
*/
|
||||
export function UsageIndicatorContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
menuItems,
|
||||
}: UsageIndicatorContextMenuProps) {
|
||||
const {
|
||||
showSetLimit,
|
||||
showUpgradeToPro,
|
||||
showUpgradeToTeam,
|
||||
showManageSeats,
|
||||
showUpgradeToEnterprise,
|
||||
showContactSupport,
|
||||
onSetLimit,
|
||||
onUpgradeToPro,
|
||||
onUpgradeToTeam,
|
||||
onManageSeats,
|
||||
onUpgradeToEnterprise,
|
||||
onContactSupport,
|
||||
} = menuItems
|
||||
|
||||
const hasLimitSection = showSetLimit
|
||||
const hasUpgradeSection =
|
||||
showUpgradeToPro || showUpgradeToTeam || showUpgradeToEnterprise || showContactSupport
|
||||
const hasTeamSection = showManageSeats
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='top' sideOffset={4}>
|
||||
{/* Limit management section */}
|
||||
{showSetLimit && onSetLimit && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onSetLimit()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Set usage limit
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Team management section */}
|
||||
{hasLimitSection && hasTeamSection && <PopoverDivider />}
|
||||
{showManageSeats && onManageSeats && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onManageSeats()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Manage seats
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Upgrade section */}
|
||||
{(hasLimitSection || hasTeamSection) && hasUpgradeSection && <PopoverDivider />}
|
||||
{showUpgradeToPro && onUpgradeToPro && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToPro()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showUpgradeToTeam && onUpgradeToTeam && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToTeam()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Team
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showUpgradeToEnterprise && onUpgradeToEnterprise && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onUpgradeToEnterprise()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Upgrade to Enterprise
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showContactSupport && onContactSupport && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onContactSupport()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Contact support
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import {
|
||||
getFilledPillColor,
|
||||
USAGE_PILL_COLORS,
|
||||
USAGE_THRESHOLDS,
|
||||
} from '@/lib/billing/client/usage-visualization'
|
||||
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { UsageIndicatorContextMenu } from './usage-indicator-context-menu'
|
||||
|
||||
const logger = createLogger('UsageIndicator')
|
||||
|
||||
@@ -188,6 +191,8 @@ interface UsageIndicatorProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF'
|
||||
|
||||
/**
|
||||
* Displays a visual usage indicator with animated pill bar.
|
||||
*/
|
||||
@@ -196,6 +201,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
const { onOperationConfirmed } = useSocket()
|
||||
const queryClient = useQueryClient()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef: contextMenuRef,
|
||||
handleContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
useEffect(() => {
|
||||
const handleOperationConfirmed = () => {
|
||||
@@ -266,6 +280,96 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
|
||||
const filledColor = getFilledPillColor(isCritical, isWarning)
|
||||
|
||||
const isFree = planType === 'free'
|
||||
const isPro = planType === 'pro'
|
||||
const isTeam = planType === 'team'
|
||||
const isEnterprise = planType === 'enterprise'
|
||||
|
||||
const handleUpgradeToPro = useCallback(async () => {
|
||||
try {
|
||||
await handleUpgrade('pro')
|
||||
} catch (error) {
|
||||
logger.error('Failed to upgrade to Pro', { error })
|
||||
}
|
||||
}, [handleUpgrade])
|
||||
|
||||
const handleUpgradeToTeam = useCallback(async () => {
|
||||
try {
|
||||
await handleUpgrade('team')
|
||||
} catch (error) {
|
||||
logger.error('Failed to upgrade to Team', { error })
|
||||
}
|
||||
}, [handleUpgrade])
|
||||
|
||||
const handleSetLimit = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
|
||||
}, [])
|
||||
|
||||
const handleManageSeats = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'team' } }))
|
||||
}, [])
|
||||
|
||||
const handleUpgradeToEnterprise = useCallback(() => {
|
||||
window.open(TYPEFORM_ENTERPRISE_URL, '_blank')
|
||||
}, [])
|
||||
|
||||
const handleContactSupport = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
}, [])
|
||||
|
||||
const contextMenuItems = useMemo(
|
||||
() => ({
|
||||
// Set limit: Only for Pro and Team admins (not free, not enterprise)
|
||||
showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise,
|
||||
// Upgrade to Pro: Only for free users
|
||||
showUpgradeToPro: isFree,
|
||||
// Upgrade to Team: Free users and Pro users with billing permission
|
||||
showUpgradeToTeam: isFree || (isPro && userCanManageBilling),
|
||||
// Manage seats: Only for Team admins
|
||||
showManageSeats: isTeam && userCanManageBilling,
|
||||
// Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise)
|
||||
showUpgradeToEnterprise: isTeam && userCanManageBilling,
|
||||
// Contact support: Only for Enterprise admins
|
||||
showContactSupport: isEnterprise && userCanManageBilling,
|
||||
onSetLimit: handleSetLimit,
|
||||
onUpgradeToPro: handleUpgradeToPro,
|
||||
onUpgradeToTeam: handleUpgradeToTeam,
|
||||
onManageSeats: handleManageSeats,
|
||||
onUpgradeToEnterprise: handleUpgradeToEnterprise,
|
||||
onContactSupport: handleContactSupport,
|
||||
}),
|
||||
[
|
||||
isFree,
|
||||
isPro,
|
||||
isTeam,
|
||||
isEnterprise,
|
||||
userCanManageBilling,
|
||||
handleSetLimit,
|
||||
handleUpgradeToPro,
|
||||
handleUpgradeToTeam,
|
||||
handleManageSeats,
|
||||
handleUpgradeToEnterprise,
|
||||
handleContactSupport,
|
||||
]
|
||||
)
|
||||
|
||||
// Check if any context menu items will be visible
|
||||
const hasContextMenuItems =
|
||||
contextMenuItems.showSetLimit ||
|
||||
contextMenuItems.showUpgradeToPro ||
|
||||
contextMenuItems.showUpgradeToTeam ||
|
||||
contextMenuItems.showManageSeats ||
|
||||
contextMenuItems.showUpgradeToEnterprise ||
|
||||
contextMenuItems.showContactSupport
|
||||
|
||||
const handleContextMenuWithCheck = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!hasContextMenuItems) return
|
||||
handleContextMenu(e)
|
||||
},
|
||||
[hasContextMenuItems, handleContextMenu]
|
||||
)
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [wavePosition, setWavePosition] = useState<number | null>(null)
|
||||
|
||||
@@ -359,82 +463,93 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className='flex h-[18px] items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{showPlanText && (
|
||||
<>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{PLAN_NAMES[planType]}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
|
||||
{statusText.isError ? (
|
||||
<span className='font-medium text-[12px] text-[var(--text-error)]'>
|
||||
{statusText.text}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenuWithCheck}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className='flex h-[18px] items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{showPlanText && (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.limit.toFixed(2)}
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{PLAN_NAMES[planType]}
|
||||
</span>
|
||||
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
|
||||
{statusText.isError ? (
|
||||
<span className='font-medium text-[12px] text-[var(--text-error)]'>
|
||||
{statusText.text}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
|
||||
${usage.limit.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{badgeConfig.show && (
|
||||
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
|
||||
{badgeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{badgeConfig.show && (
|
||||
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
|
||||
{badgeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pills row */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
|
||||
{/* Pills row */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
|
||||
|
||||
const backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
const backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
|
||||
if (isHovered && wavePosition !== null) {
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const pillOffsetFromStart = i - startAnimationIndex
|
||||
if (isHovered && wavePosition !== null) {
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const pillOffsetFromStart = i - startAnimationIndex
|
||||
|
||||
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = (wavePosition - headIndex) * 100
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
|
||||
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = (wavePosition - headIndex) * 100
|
||||
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
transition: isHovered ? 'none' : 'background-color 200ms',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
transition: isHovered ? 'none' : 'background-color 200ms',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageIndicatorContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
menuItems={contextMenuItems}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface EmptyAreaContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when create workflow is clicked
|
||||
*/
|
||||
onCreateWorkflow: () => void
|
||||
/**
|
||||
* Callback when create folder is clicked
|
||||
*/
|
||||
onCreateFolder: () => void
|
||||
/**
|
||||
* Whether create workflow is disabled
|
||||
*/
|
||||
disableCreateWorkflow?: boolean
|
||||
/**
|
||||
* Whether create folder is disabled
|
||||
*/
|
||||
disableCreateFolder?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for sidebar empty area.
|
||||
* Displays options to create a workflow or folder when right-clicking on empty space.
|
||||
*/
|
||||
export function EmptyAreaContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCreateWorkflow,
|
||||
onCreateFolder,
|
||||
disableCreateWorkflow = false,
|
||||
disableCreateFolder = false,
|
||||
}: EmptyAreaContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
disabled={disableCreateWorkflow}
|
||||
onClick={() => {
|
||||
onCreateWorkflow()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create workflow
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
disabled={disableCreateFolder}
|
||||
onClick={() => {
|
||||
onCreateFolder()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create folder
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EmptyAreaContextMenu } from './empty-area-context-menu'
|
||||
@@ -3,9 +3,11 @@
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
|
||||
import {
|
||||
useContextMenu,
|
||||
useDragDrop,
|
||||
useWorkflowSelection,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -36,6 +38,9 @@ interface WorkflowListProps {
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
onCreateWorkflow?: () => void
|
||||
onCreateFolder?: () => void
|
||||
disableCreate?: boolean
|
||||
}
|
||||
|
||||
const DropIndicatorLine = memo(function DropIndicatorLine({
|
||||
@@ -63,6 +68,9 @@ export function WorkflowList({
|
||||
handleFileChange,
|
||||
fileInputRef,
|
||||
scrollContainerRef,
|
||||
onCreateWorkflow,
|
||||
onCreateFolder,
|
||||
disableCreate = false,
|
||||
}: WorkflowListProps) {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
@@ -72,6 +80,14 @@ export function WorkflowList({
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
|
||||
const {
|
||||
isOpen: isEmptyAreaMenuOpen,
|
||||
position: emptyAreaMenuPosition,
|
||||
menuRef: emptyAreaMenuRef,
|
||||
handleContextMenu: handleEmptyAreaContextMenu,
|
||||
closeMenu: closeEmptyAreaMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const {
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
@@ -351,36 +367,71 @@ export function WorkflowList({
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
const handleContainerContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isOnEmptyArea =
|
||||
target === e.currentTarget ||
|
||||
target.classList.contains('space-y-[2px]') ||
|
||||
target.closest('[data-empty-area]')
|
||||
if (!isOnEmptyArea) return
|
||||
if (!onCreateWorkflow && !onCreateFolder) return
|
||||
handleEmptyAreaContextMenu(e)
|
||||
},
|
||||
[handleEmptyAreaContextMenu, onCreateWorkflow, onCreateFolder]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
|
||||
<>
|
||||
<div
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
className='flex min-h-full flex-col pb-[8px]'
|
||||
onClick={handleContainerClick}
|
||||
onContextMenu={handleContainerContextMenu}
|
||||
data-empty-area
|
||||
>
|
||||
{/* Root drop target highlight overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className='space-y-[2px]'>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
data-empty-area
|
||||
>
|
||||
{/* Root drop target highlight overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className='space-y-[2px]' data-empty-area>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{onCreateWorkflow && onCreateFolder && (
|
||||
<EmptyAreaContextMenu
|
||||
isOpen={isEmptyAreaMenuOpen}
|
||||
position={emptyAreaMenuPosition}
|
||||
menuRef={emptyAreaMenuRef}
|
||||
onClose={closeEmptyAreaMenu}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onCreateFolder={onCreateFolder}
|
||||
disableCreateWorkflow={disableCreate}
|
||||
disableCreateFolder={disableCreate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -639,6 +639,9 @@ export function Sidebar() {
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user