improvement(ui/ux) (#831)

* complete: workspace header, workspace selector

* finished search modal

* completed workflow selector

* finished invite modal

* finished help modal
This commit is contained in:
Emir Karabeg
2025-08-04 20:32:01 -07:00
committed by GitHub
parent 41b1357afb
commit 063734f02c
48 changed files with 3100 additions and 3237 deletions

View File

@@ -126,8 +126,8 @@
--destructive-foreground: 0 0% 98%;
/* Border & Input Colors */
--border: 0 0% 22.7%;
--input: 0 0% 22.7%;
--border: 0 0% 16.1%;
--input: 0 0% 16.1%;
--ring: 0 0% 83.9%;
/* Scrollbar Properties */

View File

@@ -1,5 +1,6 @@
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { GeistSans } from 'geist/font/sans'
import type { Metadata, Viewport } from 'next'
import { PublicEnvScript } from 'next-runtime-env'
import { isHosted } from '@/lib/environment'
@@ -162,7 +163,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en' suppressHydrationWarning>
<html lang='en' suppressHydrationWarning className={GeistSans.className}>
<head>
{/* Structured Data for SEO */}
<script

View File

@@ -18,7 +18,7 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
const logger = createLogger('EditChunkModal')

View File

@@ -13,7 +13,6 @@ import {
} from '@/components/ui'
import { TAG_SLOTS } from '@/lib/constants/knowledge'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import {
CreateChunkModal,
DeleteChunkModal,
@@ -26,6 +25,7 @@ import {
type DocumentTag,
DocumentTagEntry,
} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useDocumentChunks } from '@/hooks/use-knowledge'
import { useTagDefinitions } from '@/hooks/use-tag-definitions'
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'

View File

@@ -30,7 +30,6 @@ import { Checkbox } from '@/components/ui/checkbox'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import {
ActionBar,
KnowledgeBaseLoading,
@@ -42,6 +41,7 @@ import {
PrimaryButton,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'

View File

@@ -3,7 +3,7 @@ import { Circle, CircleOff, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface ActionBarProps {
selectedCount: number

View File

@@ -4,7 +4,6 @@ import { useMemo, useState } from 'react'
import { LibraryBig, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import {
BaseOverview,
CreateModal,
@@ -14,6 +13,7 @@ import {
PrimaryButton,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'

View File

@@ -1,4 +1,4 @@
import Providers from '@/app/workspace/[workspaceId]/w/components/providers/providers'
import Providers from '@/app/workspace/[workspaceId]/providers/providers'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {

View File

@@ -2,8 +2,8 @@
import React from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { ThemeProvider } from '@/app/workspace/[workspaceId]/w/components/providers/theme-provider'
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface ProvidersProps {
children: React.ReactNode

View File

@@ -135,7 +135,7 @@ interface TemplateCardProps {
// Skeleton component for loading states
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[14px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
@@ -180,7 +180,7 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[14px] border-border border-l bg-secondary p-2'>
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
@@ -365,7 +365,7 @@ export function TemplateCard({
return (
<div
className={cn(
'group rounded-[14px] border bg-card shadow-xs transition-all duration-200 hover:border-border/80 hover:shadow-sm',
'group rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
@@ -379,7 +379,7 @@ export function TemplateCard({
{/* Icon container */}
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[8px]',
// Use CSS class if iconColor doesn't start with #
iconColor?.startsWith('#') ? '' : iconColor || 'bg-blue-500'
)}
@@ -401,7 +401,7 @@ export function TemplateCard({
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-all duration-200',
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
@@ -411,7 +411,7 @@ export function TemplateCard({
<button
onClick={handleUseClick}
className={cn(
'rounded-md px-3 py-1 font-medium font-sans text-white text-xs transition-all duration-200',
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[#701FFC] hover:bg-[#6518E6]',
'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
@@ -444,7 +444,7 @@ export function TemplateCard({
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[14px] border-border border-l bg-secondary p-2'>
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
@@ -455,7 +455,7 @@ export function TemplateCard({
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded'
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
@@ -470,7 +470,7 @@ export function TemplateCard({
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded bg-muted-foreground'
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
@@ -486,7 +486,7 @@ export function TemplateCard({
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded'
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',

View File

@@ -46,7 +46,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
setIsExporting(true)
try {
const yamlContent = getYaml()
const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '_')}_workflow.yaml`
const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.yaml`
downloadFile(yamlContent, filename, 'text/yaml')
logger.info('Workflow exported as YAML')

View File

@@ -3,7 +3,7 @@
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface ConnectionStatusProps {
isConnected: boolean

View File

@@ -34,13 +34,12 @@ import {
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
DeploymentControls,
ExportControls,
TemplateModal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { WorkflowTextEditorModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor-modal'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import {
getKeyboardShortcutText,
@@ -444,16 +443,18 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this workflow? This action cannot be undone.
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteWorkflow}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
Delete
</AlertDialogAction>
@@ -514,36 +515,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
)
}
/**
* Render YAML editor button
*/
const renderYamlEditorButton = () => {
const canEdit = userPermissions.canEdit
const isDisabled = isExecuting || isDebugging || !canEdit
const getTooltipText = () => {
if (!canEdit) return 'Admin permission required to edit YAML'
if (isDebugging) return 'Cannot edit YAML while debugging'
if (isExecuting) return 'Cannot edit YAML while workflow is running'
return 'Edit workflow as YAML/JSON'
}
return (
<Tooltip>
<TooltipTrigger asChild>
<WorkflowTextEditorModal
disabled={isDisabled}
className={cn(
'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs',
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-secondary'
)}
/>
</TooltipTrigger>
<TooltipContent>{getTooltipText()}</TooltipContent>
</Tooltip>
)
}
/**
* Render auto-layout button
*/
@@ -1029,7 +1000,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderDisconnectionNotice()}
{renderToggleButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderYamlEditorButton()}
{isExpanded && renderAutoLayoutButton()}
{isExpanded && renderDuplicateButton()}
{renderDeleteButton()}

View File

@@ -2,7 +2,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lu
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

View File

@@ -8,7 +8,7 @@ import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, validateName } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution/store'

View File

@@ -13,7 +13,7 @@ import ReactFlow, {
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'

View File

@@ -1,171 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { NavigationPosition, NavigationSection } from '../search-modal'
export function useSearchNavigation(sections: NavigationSection[], open: boolean) {
const [position, setPosition] = useState<NavigationPosition>({ sectionIndex: 0, itemIndex: 0 })
const scrollRefs = useRef<Map<string, HTMLElement>>(new Map())
const lastItemIndex = useRef<Map<string, number>>(new Map())
useEffect(() => {
if (open) {
setPosition({ sectionIndex: 0, itemIndex: 0 })
}
}, [open, sections])
const getCurrentItem = useCallback(() => {
const section = sections[position.sectionIndex]
if (!section || position.itemIndex >= section.items.length) return null
return {
section,
item: section.items[position.itemIndex],
position,
}
}, [sections, position])
const navigate = useCallback(
(direction: 'up' | 'down' | 'left' | 'right') => {
setPosition((prev) => {
const section = sections[prev.sectionIndex]
if (!section) return prev
switch (direction) {
case 'down':
if (section.type === 'grid' && section.gridCols) {
const totalRows = section.id === 'templates' ? 1 : 2
const currentCol = Math.floor(prev.itemIndex / totalRows)
const currentRow = prev.itemIndex % totalRows
if (currentRow < totalRows - 1) {
const nextIndex = currentCol * totalRows + (currentRow + 1)
if (nextIndex < section.items.length) {
return { ...prev, itemIndex: nextIndex }
}
}
} else if (section.type === 'list') {
if (prev.itemIndex < section.items.length - 1) {
return { ...prev, itemIndex: prev.itemIndex + 1 }
}
}
if (prev.sectionIndex < sections.length - 1) {
const nextSection = sections[prev.sectionIndex + 1]
lastItemIndex.current.set(section.id, prev.itemIndex)
const rememberedIndex = lastItemIndex.current.get(nextSection.id)
const targetIndex =
rememberedIndex !== undefined
? Math.min(rememberedIndex, nextSection.items.length - 1)
: 0
return { sectionIndex: prev.sectionIndex + 1, itemIndex: targetIndex }
}
return prev
case 'up':
if (section.type === 'grid' && section.gridCols) {
const totalRows = section.id === 'templates' ? 1 : 2
const currentCol = Math.floor(prev.itemIndex / totalRows)
const currentRow = prev.itemIndex % totalRows
if (currentRow > 0) {
const prevIndex = currentCol * totalRows + (currentRow - 1)
return { ...prev, itemIndex: prevIndex }
}
} else if (section.type === 'list') {
if (prev.itemIndex > 0) {
return { ...prev, itemIndex: prev.itemIndex - 1 }
}
}
if (prev.sectionIndex > 0) {
const prevSection = sections[prev.sectionIndex - 1]
lastItemIndex.current.set(section.id, prev.itemIndex)
const rememberedIndex = lastItemIndex.current.get(prevSection.id)
const targetIndex =
rememberedIndex !== undefined
? Math.min(rememberedIndex, prevSection.items.length - 1)
: prevSection.items.length - 1
return { sectionIndex: prev.sectionIndex - 1, itemIndex: targetIndex }
}
return prev
case 'right':
if (section.type === 'grid' && section.gridCols) {
const totalRows = section.id === 'templates' ? 1 : 2
const currentCol = Math.floor(prev.itemIndex / totalRows)
const currentRow = prev.itemIndex % totalRows
const totalCols = Math.ceil(section.items.length / totalRows)
if (currentCol < totalCols - 1) {
const nextIndex = (currentCol + 1) * totalRows + currentRow
if (nextIndex < section.items.length) {
return { ...prev, itemIndex: nextIndex }
}
}
} else if (section.type === 'list') {
if (prev.itemIndex < section.items.length - 1) {
return { ...prev, itemIndex: prev.itemIndex + 1 }
}
}
return prev
case 'left':
if (section.type === 'grid' && section.gridCols) {
const totalRows = section.id === 'templates' ? 1 : 2
const currentCol = Math.floor(prev.itemIndex / totalRows)
const currentRow = prev.itemIndex % totalRows
if (currentCol > 0) {
const prevIndex = (currentCol - 1) * totalRows + currentRow
return { ...prev, itemIndex: prevIndex }
}
} else if (section.type === 'list') {
if (prev.itemIndex > 0) {
return { ...prev, itemIndex: prev.itemIndex - 1 }
}
}
return prev
default:
return prev
}
})
},
[sections]
)
const scrollIntoView = useCallback(() => {
const current = getCurrentItem()
if (!current) return
const container = scrollRefs.current.get(current.section.id)
if (!container) return
const itemSelector = `[data-nav-item="${current.section.id}-${current.position.itemIndex}"]`
const element = container.querySelector(itemSelector) as HTMLElement
if (!element) return
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
})
}, [getCurrentItem])
useEffect(() => {
if (open) {
const timer = setTimeout(scrollIntoView, 10)
return () => clearTimeout(timer)
}
}, [position, open, scrollIntoView])
return {
position,
navigate,
getCurrentItem,
scrollRefs,
}
}

View File

@@ -2,43 +2,78 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { logger } from '@sentry/nextjs'
import { File, Folder, Plus, Upload } from 'lucide-react'
import { Folder, Plus, Upload } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import {
ImportControls,
type ImportControlsRef,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
// Constants
const TIMERS = {
LONG_PRESS_DELAY: 500,
CLOSE_DELAY: 150,
} as const
interface CreateMenuProps {
onCreateWorkflow: (folderId?: string) => Promise<string>
isCollapsed?: boolean
isCreatingWorkflow?: boolean
}
export function CreateMenu({
onCreateWorkflow,
isCollapsed,
isCreatingWorkflow = false,
}: CreateMenuProps) {
export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: CreateMenuProps) {
// State
const [isCreating, setIsCreating] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [pressTimer, setPressTimer] = useState<NodeJS.Timeout | null>(null)
const [closeTimer, setCloseTimer] = useState<NodeJS.Timeout | null>(null)
// Hooks
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { createFolder } = useFolderStore()
const { createWorkflow } = useWorkflowRegistry()
const userPermissions = useUserPermissionsContext()
const fileInputRef = useRef<HTMLInputElement>(null)
// Ref for the file input that will be used by ImportControls
const importControlsRef = useRef<ImportControlsRef>(null)
// Timer management utilities
const clearAllTimers = useCallback(() => {
if (pressTimer) {
window.clearTimeout(pressTimer)
setPressTimer(null)
}
if (closeTimer) {
window.clearTimeout(closeTimer)
setCloseTimer(null)
}
}, [pressTimer, closeTimer])
const clearCloseTimer = useCallback(() => {
if (closeTimer) {
window.clearTimeout(closeTimer)
setCloseTimer(null)
}
}, [closeTimer])
const startCloseTimer = useCallback(() => {
const timer = setTimeout(() => {
setIsOpen(false)
setCloseTimer(null)
}, TIMERS.CLOSE_DELAY)
setCloseTimer(timer)
}, [])
const openPopover = useCallback(() => {
clearCloseTimer()
setIsOpen(true)
}, [clearCloseTimer])
// Action handlers
const handleCreateWorkflow = useCallback(async () => {
if (isCreatingWorkflow) {
logger.info('Workflow creation already in progress, ignoring request')
@@ -48,10 +83,7 @@ export function CreateMenu({
setIsOpen(false)
try {
// Call the parent's workflow creation function and wait for the ID
const workflowId = await onCreateWorkflow()
// Navigate to the new workflow
if (workflowId) {
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
}
@@ -63,96 +95,152 @@ export function CreateMenu({
const handleCreateFolder = useCallback(async () => {
setIsOpen(false)
if (isCreating) {
logger.info('Folder creation already in progress, ignoring request')
return
}
if (!workspaceId) {
logger.error('No workspaceId available for folder creation')
if (isCreating || !workspaceId) {
logger.info('Folder creation already in progress or no workspaceId available')
return
}
try {
setIsCreating(true)
// Generate folder name using fresh data from API
const folderName = await generateFolderName(workspaceId)
await createFolder({
name: folderName,
workspaceId: workspaceId,
})
await createFolder({ name: folderName, workspaceId })
logger.info(`Created folder: ${folderName}`)
} catch (error) {
logger.error('Failed to create folder:', { error })
} finally {
setIsCreating(false)
}
}, [createFolder, workspaceId])
}, [createFolder, workspaceId, isCreating])
const handleDirectImport = useCallback(
async (content: string, filename?: string) => {
if (!content.trim()) {
logger.error('YAML content is required')
return
}
setIsImporting(true)
try {
// First validate the YAML without importing
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(content)
if (!yamlWorkflow || parseErrors.length > 0) {
logger.error('Failed to parse YAML:', { errors: parseErrors })
return
}
// Generate workflow name from filename or fallback to time-based name
const getWorkflowName = () => {
if (filename) {
// Remove file extension and use the filename
const nameWithoutExtension = filename.replace(/\.(ya?ml)$/i, '')
return (
nameWithoutExtension.trim() || `Imported Workflow - ${new Date().toLocaleString()}`
)
}
return `Imported Workflow - ${new Date().toLocaleString()}`
}
// Create a new workflow
const newWorkflowId = await createWorkflow({
name: getWorkflowName(),
description: 'Workflow imported from YAML',
workspaceId,
})
// Use the new consolidated YAML endpoint to import the workflow
const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
yamlContent: content,
description: 'Workflow imported from YAML',
source: 'import',
applyAutoLayout: true,
createCheckpoint: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Import failed:', {
message: errorData.message || `HTTP ${response.status}: ${response.statusText}`,
})
return
}
const result = await response.json()
// Navigate to the new workflow AFTER import is complete
if (result.success) {
logger.info('Navigating to imported workflow')
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
logger.info('YAML import completed successfully')
} else {
logger.error('Import failed:', { errors: result.errors || [] })
}
} catch (error) {
logger.error('Failed to import YAML workflow:', { error })
} finally {
setIsImporting(false)
}
},
[createWorkflow, workspaceId, router]
)
const handleImportWorkflow = useCallback(() => {
setIsOpen(false)
// Trigger the file upload from ImportControls component
importControlsRef.current?.triggerFileUpload()
fileInputRef.current?.click()
}, [])
// Handle direct click for workflow creation
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
// Import directly with filename
await handleDirectImport(content, file.name)
} catch (error) {
logger.error('Failed to read file:', { error })
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
[handleDirectImport]
)
// Button event handlers
const handleButtonClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Clear any existing press timer
if (pressTimer) {
window.clearTimeout(pressTimer)
setPressTimer(null)
}
// Direct workflow creation on click
clearAllTimers()
handleCreateWorkflow()
},
[handleCreateWorkflow, pressTimer]
[clearAllTimers, handleCreateWorkflow]
)
// Handle hover to show popover
const handleMouseEnter = useCallback(() => {
setIsOpen(true)
}, [])
const handleMouseLeave = useCallback(() => {
if (pressTimer) {
window.clearTimeout(pressTimer)
setPressTimer(null)
}
setIsOpen(false)
}, [pressTimer])
// Handle dropdown content hover
const handlePopoverMouseEnter = useCallback(() => {
setIsOpen(true)
}, [])
const handlePopoverMouseLeave = useCallback(() => {
setIsOpen(false)
}, [])
// Handle right-click to show popover
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsOpen(true)
}, [])
// Handle long press to show popover
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 0) {
// Left mouse button
const timer = setTimeout(() => {
setIsOpen(true)
setPressTimer(null)
}, 500) // 500ms for long press
}, TIMERS.LONG_PRESS_DELAY)
setPressTimer(timer)
}
}, [])
@@ -164,13 +252,46 @@ export function CreateMenu({
}
}, [pressTimer])
useEffect(() => {
return () => {
if (pressTimer) {
window.clearTimeout(pressTimer)
}
// Hover event handlers for popover control
const handleMouseEnter = useCallback(() => {
openPopover()
}, [openPopover])
const handleMouseLeave = useCallback(() => {
if (pressTimer) {
window.clearTimeout(pressTimer)
setPressTimer(null)
}
}, [pressTimer])
startCloseTimer()
}, [pressTimer, startCloseTimer])
const handlePopoverMouseEnter = useCallback(() => {
openPopover()
}, [openPopover])
const handlePopoverMouseLeave = useCallback(() => {
startCloseTimer()
}, [startCloseTimer])
// Cleanup effect
useEffect(() => {
return () => clearAllTimers()
}, [clearAllTimers])
// Styles
const menuItemClassName =
'group flex h-8 w-full cursor-pointer items-center gap-2 rounded-[8px] px-2 py-2 font-medium font-sans text-muted-foreground text-sm outline-none hover:bg-muted focus:bg-muted'
const iconClassName = 'h-4 w-4 group-hover:text-foreground'
const textClassName = 'group-hover:text-foreground'
const popoverContentClassName = cn(
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 animate-in overflow-hidden rounded-[8px] border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=closed]:animate-out',
'w-42'
)
return (
<>
@@ -179,7 +300,7 @@ export function CreateMenu({
<Button
variant='ghost'
size='icon'
className='h-9 w-9 shrink-0 rounded-lg border bg-card shadow-xs hover:bg-accent focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
className='h-8 w-8 shrink-0 rounded-[8px] border bg-background shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
title='Create Workflow (Hover, right-click, or long press for more options)'
disabled={isCreatingWorkflow}
onClick={handleButtonClick}
@@ -193,64 +314,60 @@ export function CreateMenu({
<span className='sr-only'>Create Workflow</span>
</Button>
</PopoverTrigger>
<PopoverContent
align={isCollapsed ? 'center' : 'end'}
side={isCollapsed ? 'right' : undefined}
sideOffset={0}
className={cn(
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 animate-in overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=closed]:animate-out',
'w-48'
)}
align='end'
sideOffset={4}
className={popoverContentClassName}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseEnter={handlePopoverMouseEnter}
onMouseLeave={handlePopoverMouseLeave}
>
{/* New Workflow */}
<button
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50',
isCreatingWorkflow && 'cursor-not-allowed opacity-50'
)}
className={cn(menuItemClassName, isCreatingWorkflow && 'cursor-not-allowed opacity-50')}
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow}
>
<File className='h-4 w-4' />
{isCreatingWorkflow ? 'Creating...' : 'New workflow'}
<Plus className={iconClassName} />
<span className={textClassName}>
{isCreatingWorkflow ? 'Creating...' : 'New workflow'}
</span>
</button>
{/* New Folder */}
<button
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50',
isCreating && 'cursor-not-allowed opacity-50'
)}
className={cn(menuItemClassName, isCreating && 'cursor-not-allowed opacity-50')}
onClick={handleCreateFolder}
disabled={isCreating}
>
<Folder className='h-4 w-4' />
{isCreating ? 'Creating...' : 'New folder'}
<Folder className={iconClassName} />
<span className={textClassName}>{isCreating ? 'Creating...' : 'New folder'}</span>
</button>
{/* Import Workflow */}
{userPermissions.canEdit && (
<button
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
className={cn(menuItemClassName, isImporting && 'cursor-not-allowed opacity-50')}
onClick={handleImportWorkflow}
disabled={isImporting}
>
<Upload className='h-4 w-4' />
Import workflow
<Upload className={iconClassName} />
<span className={textClassName}>
{isImporting ? 'Importing...' : 'Import workflow'}
</span>
</button>
)}
</PopoverContent>
</Popover>
{/* Import Controls Component - handles all import functionality */}
<ImportControls
ref={importControlsRef}
disabled={!userPermissions.canEdit}
onClose={() => setIsOpen(false)}
<input
ref={fileInputRef}
type='file'
accept='.yaml,.yml'
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</>
)

View File

@@ -1,210 +0,0 @@
'use client'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { simAgentClient } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('ImportControls')
interface ImportControlsProps {
disabled?: boolean
onClose?: () => void
}
export interface ImportControlsRef {
triggerFileUpload: () => void
}
export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>(
({ disabled = false, onClose }, ref) => {
const [isImporting, setIsImporting] = useState(false)
const [yamlContent, setYamlContent] = useState('')
const [importResult, setImportResult] = useState<{
success: boolean
errors: string[]
warnings: string[]
summary?: string
} | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
// Stores and hooks
const { createWorkflow } = useWorkflowRegistry()
// Expose methods to parent component
useImperativeHandle(ref, () => ({
triggerFileUpload: () => {
fileInputRef.current?.click()
},
}))
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
setYamlContent(content)
// Import directly without showing the modal
await handleDirectImport(content)
onClose?.()
} catch (error) {
logger.error('Failed to read file:', error)
setImportResult({
success: false,
errors: [
`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
})
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDirectImport = async (content: string) => {
if (!content.trim()) {
setImportResult({
success: false,
errors: ['YAML content is required'],
warnings: [],
})
return
}
setIsImporting(true)
setImportResult(null)
try {
// First validate the YAML without importing
// Gather block registry and utilities for sim-agent
const blocks = getAllBlocks()
const blockRegistry = blocks.reduce(
(acc, block) => {
const blockType = block.type
acc[blockType] = {
...block,
id: blockType,
subBlocks: block.subBlocks || [],
outputs: block.outputs || {},
} as any
return acc
},
{} as Record<string, BlockConfig>
)
const parseResult = await simAgentClient.makeRequest('/api/yaml/parse', {
body: {
yamlContent: content,
blockRegistry,
utilities: {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
},
},
})
if (!parseResult.success || !parseResult.data?.data) {
setImportResult({
success: false,
errors: parseResult.data?.errors || [parseResult.error || 'Failed to parse YAML'],
warnings: [],
})
return
}
const yamlWorkflow = parseResult.data.data
// Create a new workflow
const newWorkflowId = await createWorkflow({
name: `Imported Workflow - ${new Date().toLocaleString()}`,
description: 'Workflow imported from YAML',
workspaceId,
})
// Use the new consolidated YAML endpoint to import the workflow
const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
yamlContent: content,
description: 'Workflow imported from YAML',
source: 'import',
applyAutoLayout: true,
createCheckpoint: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
setImportResult({
success: false,
errors: [errorData.message || `HTTP ${response.status}: ${response.statusText}`],
warnings: errorData.warnings || [],
})
return
}
const result = await response.json()
// Navigate to the new workflow AFTER import is complete
if (result.success) {
logger.info('Navigating to imported workflow')
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
}
setImportResult({
success: result.success,
errors: result.errors || [],
warnings: result.warnings || [],
summary: result.summary,
})
if (result.success) {
setYamlContent('')
logger.info('YAML import completed successfully')
}
} catch (error) {
logger.error('Failed to import YAML workflow:', error)
setImportResult({
success: false,
errors: [`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
warnings: [],
})
} finally {
setIsImporting(false)
}
}
return (
<>
{/* Hidden file input */}
<input
ref={fileInputRef}
type='file'
accept='.yaml,.yml'
onChange={handleFileUpload}
className='hidden'
/>
</>
)
}
)
ImportControls.displayName = 'ImportControls'

View File

@@ -1,2 +0,0 @@
export { CreateMenu } from './create-menu'
export { ImportControls, type ImportControlsRef } from './import-controls'

View File

@@ -1,183 +0,0 @@
'use client'
import { useState } from 'react'
import { File, Folder, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { generateSubfolderName } from '@/lib/naming'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('FolderContextMenu')
interface FolderContextMenuProps {
folderId: string
folderName: string
onCreateWorkflow: (folderId: string) => void
onDelete?: (folderId: string) => void
onStartEdit?: () => void
level: number
}
export function FolderContextMenu({
folderId,
folderName,
onCreateWorkflow,
onDelete,
onStartEdit,
level,
}: FolderContextMenuProps) {
const [isCreating, setIsCreating] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string
// Get user permissions for the workspace
const userPermissions = useUserPermissionsContext()
const { createFolder, deleteFolder, setExpanded } = useFolderStore()
const handleCreateWorkflow = () => {
// Ensure folder is expanded so user can see the new workflow
setExpanded(folderId, true)
onCreateWorkflow(folderId)
}
const handleCreateSubfolder = async () => {
if (isCreating) {
logger.info('Subfolder creation already in progress, ignoring request')
return
}
if (!workspaceId) {
logger.error('No workspaceId available for subfolder creation')
return
}
try {
setIsCreating(true)
// Ensure parent folder is expanded so user can see the new subfolder
setExpanded(folderId, true)
// Generate subfolder name using fresh data from API
const subfolderName = await generateSubfolderName(workspaceId, folderId)
await createFolder({
name: subfolderName,
workspaceId: workspaceId,
parentId: folderId,
})
logger.info(`Created subfolder: ${subfolderName}`)
} catch (error) {
logger.error('Failed to create subfolder:', { error })
} finally {
setIsCreating(false)
}
}
const handleRename = () => {
if (onStartEdit) {
onStartEdit()
}
}
const handleDelete = async () => {
if (onDelete) {
onDelete(folderId)
} else {
// Default delete behavior with proper error handling
try {
await deleteFolder(folderId, workspaceId)
logger.info(`Successfully deleted folder from context menu: ${folderName}`)
} catch (error) {
logger.error('Failed to delete folder from context menu:', { error, folderId, folderName })
}
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className='h-3 w-3' />
<span className='sr-only'>Folder options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
onClick={(e) => e.stopPropagation()}
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
{userPermissions.canEdit && (
<>
<DropdownMenuItem
onClick={handleCreateWorkflow}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<File className='mr-2 h-4 w-4' />
New Workflow
</DropdownMenuItem>
{level === 0 && (
<DropdownMenuItem
onClick={handleCreateSubfolder}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Folder className='mr-2 h-4 w-4' />
New Subfolder
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleRename}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Pencil className='mr-2 h-4 w-4' />
Rename
</DropdownMenuItem>
</>
)}
{userPermissions.canAdmin ? (
<DropdownMenuItem
onClick={handleDelete}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DropdownMenuItem
className='cursor-not-allowed rounded-md px-3 py-2 font-[380] text-muted-foreground text-sm opacity-50'
onClick={(e) => e.preventDefault()}
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Admin access required to delete folders</p>
</TooltipContent>
</Tooltip>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { Folder, FolderOpen } from 'lucide-react'
import { Folder, FolderOpen, Pencil, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -14,9 +14,10 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { FolderContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
const logger = createLogger('FolderItem')
@@ -51,13 +52,14 @@ export function FolderItem({
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(folder.name)
const [isRenaming, setIsRenaming] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const dragStartedRef = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const params = useParams()
const workspaceId = params.workspaceId as string
const isExpanded = expandedFolders.has(folder.id)
const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingStateRef = useRef<boolean | null>(null)
const userPermissions = useUserPermissionsContext()
// Update editValue when folder name changes
useEffect(() => {
@@ -74,25 +76,8 @@ export function FolderItem({
const handleToggleExpanded = useCallback(() => {
if (isEditing) return // Don't toggle when editing
const newExpandedState = !isExpanded
toggleExpanded(folder.id)
pendingStateRef.current = newExpandedState
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current)
}
updateTimeoutRef.current = setTimeout(() => {
if (pendingStateRef.current === newExpandedState) {
updateFolderAPI(folder.id, { isExpanded: newExpandedState })
.catch(console.error)
.finally(() => {
pendingStateRef.current = null
})
}
}, 300)
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI, isEditing])
}, [folder.id, toggleExpanded, isEditing])
const handleDragStart = (e: React.DragEvent) => {
if (isEditing) return
@@ -129,14 +114,6 @@ export function FolderItem({
handleToggleExpanded()
}
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current)
}
}
}, [])
const handleStartEdit = () => {
setIsEditing(true)
setEditValue(folder.name)
@@ -210,7 +187,7 @@ export function FolderItem({
<TooltipTrigger asChild>
<div
className={clsx(
'group mx-auto mb-1 flex h-9 w-9 cursor-pointer items-center justify-center',
'group mx-auto mb-1 flex h-8 w-8 cursor-pointer items-center justify-center',
isDragging ? 'opacity-50' : ''
)}
onDragOver={onDragOver}
@@ -223,14 +200,15 @@ export function FolderItem({
>
<div
className={clsx(
'flex h-4 w-4 items-center justify-center rounded transition-colors hover:bg-accent/50',
dragOver ? 'ring-2 ring-blue-500' : ''
'relative flex h-[14px] w-[14px] items-center justify-center rounded transition-colors hover:bg-muted',
dragOver &&
'before:pointer-events-none before:absolute before:inset-0 before:rounded before:bg-muted/20 before:ring-2 before:ring-muted-foreground/60'
)}
>
{isExpanded ? (
<FolderOpen className='h-3 w-3 text-foreground/70 dark:text-foreground/60' />
<FolderOpen className='h-[14px] w-[14px] text-foreground/70 group-hover:text-foreground dark:text-foreground/60' />
) : (
<Folder className='h-3 w-3 text-foreground/70 dark:text-foreground/60' />
<Folder className='h-[14px] w-[14px] text-foreground/70 group-hover:text-foreground dark:text-foreground/60' />
)}
</div>
</div>
@@ -244,26 +222,26 @@ export function FolderItem({
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className='break-words'>
Are you sure you want to delete "
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
{folder.name}
</span>
"?
</AlertDialogTitle>
<AlertDialogTitle>Delete folder?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the folder and all its contents, including subfolders
and workflows. This action cannot be undone.
Deleting this folder will permanently remove all associated workflows, logs, and
knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{isDeleting ? 'Deleting...' : 'Delete Forever'}
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -274,26 +252,28 @@ export function FolderItem({
return (
<>
<div className='group mb-1' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div className='mb-1' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div
className={clsx(
'flex h-9 cursor-pointer items-center rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent/50',
'group flex h-8 cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors hover:bg-muted',
isDragging ? 'opacity-50' : '',
isFirstItem ? 'mr-[44px]' : ''
isFirstItem ? 'mr-[36px]' : ''
)}
style={{
maxWidth: isFirstItem ? `${164 - level * 20}px` : `${206 - level * 20}px`,
maxWidth: isFirstItem ? `${166 - level * 20}px` : `${206 - level * 20}px`,
}}
onClick={handleClick}
draggable={!isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className='mr-2 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
<div className='mr-2 flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center'>
{isExpanded ? (
<FolderOpen className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
<FolderOpen className='h-[14px] w-[14px] text-foreground/70 group-hover:text-foreground dark:text-foreground/60' />
) : (
<Folder className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
<Folder className='h-[14px] w-[14px] text-foreground/70 group-hover:text-foreground dark:text-foreground/60' />
)}
</div>
@@ -304,7 +284,10 @@ export function FolderItem({
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-muted-foreground text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
className={clsx(
'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
'text-muted-foreground group-hover:text-foreground'
)}
maxLength={50}
disabled={isRenaming}
onClick={(e) => e.stopPropagation()} // Prevent folder toggle when clicking input
@@ -314,21 +297,45 @@ export function FolderItem({
spellCheck='false'
/>
) : (
<span className='min-w-0 flex-1 select-none truncate text-muted-foreground'>
<span
className={clsx(
'min-w-0 flex-1 select-none truncate pr-1 font-medium text-sm',
'text-muted-foreground group-hover:text-foreground'
)}
>
{folder.name}
</span>
)}
{!isEditing && (
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<FolderContextMenu
folderId={folder.id}
folderName={folder.name}
onCreateWorkflow={onCreateWorkflow}
onDelete={handleDelete}
onStartEdit={handleStartEdit}
level={level}
/>
{!isEditing && isHovered && userPermissions.canEdit && (
<div
className='flex items-center justify-center gap-1'
onClick={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
onClick={(e) => {
e.stopPropagation()
handleStartEdit()
}}
>
<Pencil className='!h-3.5 !w-3.5' />
<span className='sr-only'>Rename folder</span>
</Button>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
onClick={(e) => {
e.stopPropagation()
handleDelete()
}}
>
<Trash2 className='!h-3.5 !w-3.5' />
<span className='sr-only'>Delete folder</span>
</Button>
</div>
)}
</div>
@@ -338,26 +345,24 @@ export function FolderItem({
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className='break-words'>
Are you sure you want to delete "
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
{folder.name}
</span>
"?
</AlertDialogTitle>
<AlertDialogTitle>Delete folder?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the folder and all its contents, including subfolders and
workflows. This action cannot be undone.
Deleting this folder will permanently remove all associated workflows, logs, and
knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{isDeleting ? 'Deleting...' : 'Delete Forever'}
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -2,22 +2,40 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { Pencil } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('WorkflowItem')
// Helper function to lighten a hex color
function lightenColor(hex: string, percent = 30): string {
// Remove # if present
const color = hex.replace('#', '')
// Parse RGB values
const num = Number.parseInt(color, 16)
const r = Math.min(255, Math.floor((num >> 16) + ((255 - (num >> 16)) * percent) / 100))
const g = Math.min(
255,
Math.floor(((num >> 8) & 0x00ff) + ((255 - ((num >> 8) & 0x00ff)) * percent) / 100)
)
const b = Math.min(255, Math.floor((num & 0x0000ff) + ((255 - (num & 0x0000ff)) * percent) / 100))
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
}
interface WorkflowItemProps {
workflow: WorkflowMetadata
active: boolean
isMarketplace?: boolean
isCollapsed?: boolean
level: number
isDragOver?: boolean
isFirstItem?: boolean
@@ -27,7 +45,6 @@ export function WorkflowItem({
workflow,
active,
isMarketplace,
isCollapsed,
level,
isDragOver = false,
isFirstItem = false,
@@ -36,6 +53,7 @@ export function WorkflowItem({
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(workflow.name)
const [isRenaming, setIsRenaming] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const dragStartedRef = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const params = useParams()
@@ -43,6 +61,7 @@ export function WorkflowItem({
const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore()
const isSelected = useIsWorkflowSelected(workflow.id)
const { updateWorkflow } = useWorkflowRegistry()
const userPermissions = useUserPermissionsContext()
// Update editValue when workflow name changes
useEffect(() => {
@@ -148,65 +167,26 @@ export function WorkflowItem({
})
}
if (isCollapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/workspace/${workspaceId}/w/${workflow.id}`}
data-workflow-id={workflow.id}
className={clsx(
'mx-auto mb-1 flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
active && !isDragOver
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver
? 'bg-accent/70'
: '',
isDragging ? 'opacity-50' : ''
)}
draggable={!isMarketplace && !isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleClick}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflow.color }}
/>
</Link>
</TooltipTrigger>
<TooltipContent side='right'>
<p className='max-w-[200px] break-words'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</p>
</TooltipContent>
</Tooltip>
)
}
return (
<div className='group mb-1'>
<div className='mb-1'>
<div
className={clsx(
'flex h-9 items-center rounded-lg px-2 py-2 font-medium text-sm transition-colors',
active && !isDragOver
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-accent/70' : '',
'group flex h-8 cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
active && !isDragOver ? 'bg-muted' : 'hover:bg-muted',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-muted' : '',
isDragging ? 'opacity-50' : '',
'cursor-pointer',
isFirstItem ? 'mr-[44px]' : ''
isFirstItem ? 'mr-[36px]' : ''
)}
style={{
maxWidth: isFirstItem
? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
? `${166 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
: `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`,
}}
draggable={!isMarketplace && !isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-workflow-id={workflow.id}
>
<Link
@@ -215,9 +195,20 @@ export function WorkflowItem({
onClick={handleClick}
>
<div
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflow.color }}
/>
className='mr-2 flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden'
style={{
backgroundColor: lightenColor(workflow.color, 60),
borderRadius: '4px',
}}
>
<div
className='h-[9px] w-[9px]'
style={{
backgroundColor: workflow.color,
borderRadius: '2.571px', // Maintains same ratio as outer div (4/14 = 2.571/9)
}}
/>
</div>
{isEditing ? (
<input
ref={inputRef}
@@ -225,9 +216,12 @@ export function WorkflowItem({
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={`min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${
active && !isDragOver ? 'text-foreground' : 'text-muted-foreground'
}`}
className={clsx(
'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium font-sans text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
active && !isDragOver
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => e.preventDefault()} // Prevent navigation when clicking input
@@ -236,17 +230,57 @@ export function WorkflowItem({
autoCapitalize='off'
spellCheck='false'
/>
) : !isDragging ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<span
className={clsx(
'min-w-0 flex-1 select-none truncate pr-1 font-medium font-sans text-sm',
active && !isDragOver
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
</TooltipTrigger>
<TooltipContent side='top' align='start' sideOffset={10}>
<p>
{workflow.name}
{isMarketplace && ' (Preview)'}
</p>
</TooltipContent>
</Tooltip>
) : (
<span className='min-w-0 flex-1 select-none truncate'>
<span
className={clsx(
'min-w-0 flex-1 select-none truncate pr-1 font-medium font-sans text-sm',
active && !isDragOver
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
)}
</Link>
{!isMarketplace && !isEditing && (
{!isMarketplace && !isEditing && isHovered && userPermissions.canEdit && (
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<WorkflowContextMenu onStartEdit={handleStartEdit} />
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
onClick={(e) => {
e.stopPropagation()
handleStartEdit()
}}
>
<Pencil className='!h-3.5 !w-3.5' />
<span className='sr-only'>Rename workflow</span>
</Button>
</div>
)}
</div>

View File

@@ -16,7 +16,6 @@ const logger = createLogger('FolderTree')
interface FolderSectionProps {
folder: FolderTreeNode
level: number
isCollapsed: boolean
onCreateWorkflow: (folderId?: string) => void
workflowsByFolder: Record<string, WorkflowMetadata[]>
expandedFolders: Set<string>
@@ -32,10 +31,45 @@ interface FolderSectionProps {
isFirstItem?: boolean
}
// Helper function to count visible items, excluding content of the last expanded folder
const countVisibleItemsForLine = (
folder: FolderTreeNode,
workflowsByFolder: Record<string, WorkflowMetadata[]>,
expandedFolders: Set<string>
): number => {
if (!expandedFolders.has(folder.id)) {
return 0 // Folder is collapsed, no visible children
}
let count = 0
const workflowsInFolder = workflowsByFolder[folder.id] || []
// Count workflows in this folder
count += workflowsInFolder.length
// Count child folders
folder.children.forEach((childFolder, index) => {
const isLastChildFolder = index === folder.children.length - 1
// In the rendering order: workflows come first, then folders
// So if this is the last child folder, it's the absolute last item
const isAbsoluteLastItem = isLastChildFolder
count += 1 // The folder itself
// Only include expanded content if this is NOT the absolute last item
if (!isAbsoluteLastItem) {
count += countVisibleItemsForLine(childFolder, workflowsByFolder, expandedFolders)
}
// If this IS the last folder and it's expanded, we don't count its content
// because the line should stop at this folder's connection point
})
return count
}
function FolderSection({
folder,
level,
isCollapsed,
onCreateWorkflow,
workflowsByFolder,
expandedFolders,
@@ -61,30 +95,24 @@ function FolderSection({
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const isExpanded = expandedFolders.has(folder.id)
// Calculate the height for the vertical connecting line
const visibleItemsCount = countVisibleItemsForLine(folder, workflowsByFolder, expandedFolders)
const lineHeight = visibleItemsCount > 0 ? (visibleItemsCount - 1) * 36 + 24 : 0
return (
<div
className={clsx(
isDragOver
? isInvalidDrop
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
: ''
'relative',
isDragOver &&
(isInvalidDrop
? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-[8px] before:border before:border-destructive/50 before:bg-destructive/15'
: 'before:pointer-events-none before:absolute before:inset-0 before:rounded-[8px] before:border before:border-muted-foreground/50 before:bg-muted/20')
)}
style={
isDragOver
? {
boxShadow: isInvalidDrop
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
}
: {}
}
>
{/* Render folder */}
<div style={{ paddingLeft: isCollapsed ? '0px' : `${level * 20}px` }}>
<div style={{ paddingLeft: `${level * 20}px` }}>
<FolderItem
folder={folder}
isCollapsed={isCollapsed}
onCreateWorkflow={onCreateWorkflow}
dragOver={isDragOver}
onDragOver={handleDragOver}
@@ -99,14 +127,14 @@ function FolderSection({
{isExpanded && hasChildren && (
<div className='relative'>
{/* Vertical line from folder icon to children */}
{!isCollapsed && (workflowsInFolder.length > 0 || folder.children.length > 0) && (
{(workflowsInFolder.length > 0 || folder.children.length > 0) && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '-9px',
width: '1px',
height: `${(workflowsInFolder.length + folder.children.length - 1) * 40 + 24}px`,
height: `${lineHeight}px`,
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
@@ -119,41 +147,36 @@ function FolderSection({
{workflowsInFolder.map((workflow, index) => (
<div key={workflow.id} className='relative'>
{/* Curved corner */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
)}
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
{/* Horizontal line to workflow */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '7px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
)}
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '7px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
{/* Workflow container with proper indentation */}
<div style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}>
<div style={{ paddingLeft: `${(level + 1) * 20 + 8}px` }}>
<WorkflowItem
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isCollapsed={isCollapsed}
level={level}
isDragOver={isAnyDragOver}
/>
@@ -169,41 +192,36 @@ function FolderSection({
{folder.children.map((childFolder, index) => (
<div key={childFolder.id} className='relative'>
{/* Curved corner */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
)}
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
{/* Horizontal line to child folder */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '5px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
)}
<div style={{ paddingLeft: isCollapsed ? '0px' : '8px' }}>
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '5px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
<div style={{ paddingLeft: '8px' }}>
<FolderSection
key={childFolder.id}
folder={childFolder}
level={level + 1}
isCollapsed={isCollapsed}
onCreateWorkflow={onCreateWorkflow}
workflowsByFolder={workflowsByFolder}
expandedFolders={expandedFolders}
@@ -254,10 +272,13 @@ function useDragHandlers(
targetFolderId === draggedFolderId ||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
// Check for deep nesting (target folder already has a parent)
const wouldBeDeepNesting = targetFolderPath.length >= 1
// Check for deep nesting - prevent triple nesting (folder -> folder -> folder)
// targetFolderPath includes the target folder itself, so:
// - length 1: root folder (allow drop - creates 2 levels: target -> dropped)
// - length 2: nested folder (prevent drop - would create 3 levels: grandparent -> target -> dropped)
const wouldBeTripleNested = targetFolderPath.length >= 2
setIsInvalidDrop(isCircular || wouldBeDeepNesting)
setIsInvalidDrop(isCircular || wouldBeTripleNested)
} else {
setIsInvalidDrop(false)
}
@@ -295,7 +316,7 @@ function useDragHandlers(
const folderIdData = e.dataTransfer.getData('folder-id')
if (folderIdData) {
try {
// Check if the target folder would create more than 2 levels of nesting
// Check if the target folder would create triple nesting
const folderStore = useFolderStore.getState()
const targetFolderPath = targetFolderId ? folderStore.getFolderPath(targetFolderId) : []
@@ -315,13 +336,19 @@ function useDragHandlers(
return
}
// If target folder is already at level 1 (has 1 parent), we can't nest another folder
if (targetFolderPath.length >= 1) {
logger.info('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
// Prevent triple nesting: folder -> folder -> folder
// targetFolderPath includes the target folder itself, so:
// - length 0: dropping into root (creates 1 level)
// - length 1: dropping into root folder (creates 2 levels: target -> dropped) - allowed
// - length 2+: dropping into nested folder (creates 3+ levels) - prevent
if (targetFolderPath.length >= 2) {
logger.info(
'Cannot nest folder: Maximum 2 levels of nesting allowed (folder -> folder). Triple nesting prevented.'
)
return // Prevent the drop entirely
}
// Target folder is at root level, safe to nest
// Safe to nest - either dropping into root or into a root-level folder
await updateFolder(folderIdData, { parentId: targetFolderId })
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
} catch (error) {
@@ -342,7 +369,6 @@ function useDragHandlers(
interface FolderTreeProps {
regularWorkflows: WorkflowMetadata[]
marketplaceWorkflows: WorkflowMetadata[]
isCollapsed?: boolean
isLoading?: boolean
onCreateWorkflow: (folderId?: string) => void
}
@@ -350,7 +376,6 @@ interface FolderTreeProps {
export function FolderTree({
regularWorkflows,
marketplaceWorkflows,
isCollapsed = false,
isLoading = false,
onCreateWorkflow,
}: FolderTreeProps) {
@@ -402,7 +427,9 @@ export function FolderTree({
for (const node of nodes) {
if (currentLevel >= 2) {
// This folder is at level 2+ (too deep), add it to cleanup list
// This folder is at level 2+ (triple nested), add it to cleanup list
// Level 0: root folders, Level 1: nested in root folders (allowed)
// Level 2+: triple nested (not allowed)
deepFolders.push(node)
} else {
// Recursively check children
@@ -471,7 +498,6 @@ export function FolderTree({
key={folder.id}
folder={folder}
level={level}
isCollapsed={isCollapsed}
onCreateWorkflow={onCreateWorkflow}
workflowsByFolder={workflowsByFolder}
expandedFolders={expandedFolders}
@@ -490,22 +516,10 @@ export function FolderTree({
// Render skeleton loading state
const renderSkeletonLoading = () => {
if (isCollapsed) {
return (
<div className='space-y-1 py-2'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='mx-auto mb-1 flex h-9 w-9 items-center justify-center'>
<Skeleton className='h-4 w-4 rounded' />
</div>
))}
</div>
)
}
return (
<div className='space-y-1 py-2'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='flex h-9 items-center rounded-lg px-2 py-2'>
<div key={i} className='flex h-8 items-center rounded-lg px-2 py-2'>
<Skeleton className='mr-2 h-4 w-4 rounded' />
<Skeleton className='h-4 max-w-32 flex-1' />
</div>
@@ -519,59 +533,49 @@ export function FolderTree({
}
return (
<div className='space-y-1 py-2'>
<div className='flex h-full flex-col pt-2 pb-[6px]'>
{/* Folder tree */}
{renderFolderTree(folderTree, 0, false)}
<div className='space-y-1'>{renderFolderTree(folderTree, 0, false)}</div>
{/* Root level workflows (no folder) */}
{/* Root level workflows and drop zone - fills remaining space */}
<div
className={clsx(
'space-y-1',
rootDragOver
? rootInvalidDrop
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
: '',
// Always provide minimal drop zone when root is empty, but keep it subtle
rootWorkflows.length === 0 ? 'min-h-2 py-1' : ''
'relative flex-1',
rootDragOver &&
(rootInvalidDrop
? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-[8px] before:border before:border-destructive/50 before:bg-destructive/15'
: 'before:pointer-events-none before:absolute before:inset-0 before:rounded-[8px] before:border before:border-muted-foreground/50 before:bg-muted/20'),
// Ensure minimum height for drag target when empty
rootWorkflows.length === 0 ? 'min-h-8' : ''
)}
style={
rootDragOver
? {
boxShadow: rootInvalidDrop
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
}
: {}
}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
{rootWorkflows.map((workflow, index) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isCollapsed={isCollapsed}
level={-1}
isDragOver={rootDragOver}
isFirstItem={folderTree.length === 0 && index === 0}
/>
))}
</div>
<div className='space-y-1'>
{rootWorkflows.map((workflow, index) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
level={-1}
isDragOver={rootDragOver}
isFirstItem={folderTree.length === 0 && index === 0}
/>
))}
{/* Empty state */}
{!showLoading &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0 &&
folderTree.length === 0 &&
!isCollapsed && (
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one
to get started.
</div>
)}
{/* Empty state */}
{!showLoading &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0 &&
folderTree.length === 0 && (
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create
one to get started.
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,445 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import imageCompression from 'browser-image-compression'
import { AlertCircle, CheckCircle2, Upload, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('HelpForm')
// Define form schema
const formSchema = z.object({
email: z.string().email('Please enter a valid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other'], {
required_error: 'Please select a request type',
}),
})
type FormValues = z.infer<typeof formSchema>
// Increased maximum upload size to 20MB
const MAX_FILE_SIZE = 20 * 1024 * 1024
// Target size after compression (2MB)
const TARGET_SIZE_MB = 2
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
interface ImageWithPreview extends File {
preview: string
}
interface HelpFormProps {
onClose: () => void
}
export function HelpForm({ onClose }: HelpFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null)
const [errorMessage, setErrorMessage] = useState('')
const [images, setImages] = useState<ImageWithPreview[]>([])
const [imageError, setImageError] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
subject: '',
message: '',
type: 'bug', // Set default value to 'bug'
},
mode: 'onChange',
})
// Set default value for type on component mount
useEffect(() => {
setValue('type', 'bug')
}, [setValue])
// Scroll to top when success message appears
useEffect(() => {
if (submitStatus === 'success' && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [submitStatus])
// Clean up object URLs when component unmounts
useEffect(() => {
return () => {
images.forEach((image) => URL.revokeObjectURL(image.preview))
}
}, [images])
const compressImage = async (file: File): Promise<File> => {
// Skip compression for small files or GIFs (which don't compress well)
if (file.size < TARGET_SIZE_MB * 1024 * 1024 || file.type === 'image/gif') {
return file
}
const options = {
maxSizeMB: TARGET_SIZE_MB,
maxWidthOrHeight: 1920,
useWebWorker: true,
fileType: file.type,
// Ensure we maintain proper file naming and MIME types
initialQuality: 0.8,
alwaysKeepResolution: true,
}
try {
const compressedFile = await imageCompression(file, options)
// Create a new File object with the original name and type to ensure compatibility
return new File([compressedFile], file.name, {
type: file.type,
lastModified: Date.now(),
})
} catch (error) {
logger.warn('Image compression failed, using original file:', { error })
return file
}
}
const processFiles = async (files: FileList | File[]) => {
setImageError(null)
if (!files || files.length === 0) return
setIsProcessing(true)
try {
const newImages: ImageWithPreview[] = []
let hasError = false
for (const file of Array.from(files)) {
// Check file size
if (file.size > MAX_FILE_SIZE) {
setImageError(`File ${file.name} is too large. Maximum size is 20MB.`)
hasError = true
continue
}
// Check file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setImageError(
`File ${file.name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.`
)
hasError = true
continue
}
// Compress the image (behind the scenes)
const compressedFile = await compressImage(file)
// Create preview URL
const imageWithPreview = Object.assign(compressedFile, {
preview: URL.createObjectURL(compressedFile),
}) as ImageWithPreview
newImages.push(imageWithPreview)
}
if (!hasError && newImages.length > 0) {
setImages((prev) => [...prev, ...newImages])
}
} catch (error) {
logger.error('Error processing images:', { error })
setImageError('An error occurred while processing images. Please try again.')
} finally {
setIsProcessing(false)
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
// Update the existing handleFileChange function to use processFiles
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
await processFiles(e.target.files)
}
}
// Handle drag events
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const removeImage = (index: number) => {
setImages((prev) => {
// Revoke the URL to avoid memory leaks
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
// Create FormData to handle file uploads
const formData = new FormData()
// Add form fields
formData.append('email', data.email)
formData.append('subject', data.subject)
formData.append('message', data.message)
formData.append('type', data.type)
// Add images
images.forEach((image, index) => {
formData.append(`image_${index}`, image)
})
const response = await fetch('/api/help', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to submit help request')
}
setSubmitStatus('success')
reset()
// Clean up image previews
images.forEach((image) => URL.revokeObjectURL(image.preview))
setImages([])
} catch (error) {
logger.error('Error submitting help request:', { error })
setSubmitStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'An unknown error occurred')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className='flex h-full flex-col'>
{/* Scrollable Content */}
<div
ref={scrollContainerRef}
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
>
<div className='py-4'>
{submitStatus === 'success' ? (
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
<div className='flex items-start gap-4 py-1'>
<div className='mt-[-1.5px] flex-shrink-0'>
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
</div>
<div className='mr-4 flex-1 space-y-2'>
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
<span className='font-medium text-green-600 dark:text-green-400'>Success</span>
</AlertTitle>
<AlertDescription className='text-green-600 dark:text-green-400'>
Your request has been submitted successfully. We'll get back to you soon.
</AlertDescription>
</div>
</div>
</Alert>
) : submitStatus === 'error' ? (
<Alert variant='destructive' className='mb-6'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{errorMessage || 'There was an error submitting your request. Please try again.'}
</AlertDescription>
</Alert>
) : null}
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='type'>Request</Label>
<Select defaultValue='bug' onValueChange={(value) => setValue('type', value as any)}>
<SelectTrigger id='type' className={errors.type ? 'border-red-500' : ''}>
<SelectValue placeholder='Select a request type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='bug'>Bug Report</SelectItem>
<SelectItem value='feedback'>Feedback</SelectItem>
<SelectItem value='feature_request'>Feature Request</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent>
</Select>
{errors.type && <p className='mt-1 text-red-500 text-sm'>{errors.type.message}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
placeholder='your.email@example.com'
{...register('email')}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && <p className='mt-1 text-red-500 text-sm'>{errors.email.message}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='subject'>Subject</Label>
<Input
id='subject'
placeholder='Brief description of your request'
{...register('subject')}
className={errors.subject ? 'border-red-500' : ''}
/>
{errors.subject && (
<p className='mt-1 text-red-500 text-sm'>{errors.subject.message}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='message'>Message</Label>
<Textarea
id='message'
placeholder='Please provide details about your request...'
rows={5}
{...register('message')}
className={errors.message ? 'border-red-500' : ''}
/>
{errors.message && (
<p className='mt-1 text-red-500 text-sm'>{errors.message.message}</p>
)}
</div>
{/* Image Upload Section */}
<div className='mt-6 space-y-2'>
<Label>Attach Images (Optional)</Label>
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex items-center gap-4 ${
isDragging ? 'rounded-md bg-primary/5 p-2' : ''
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileChange}
className='hidden'
multiple
/>
<Button
type='button'
variant='outline'
onClick={() => fileInputRef.current?.click()}
className='flex items-center justify-center gap-2'
>
<Upload className='h-4 w-4' />
Upload Images
</Button>
<p className='text-muted-foreground text-xs'>
Drop images here or click to upload. Max 20MB per image.
</p>
</div>
{imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>}
{isProcessing && (
<p className='text-muted-foreground text-sm'>Processing images...</p>
)}
</div>
{/* Image Preview Section */}
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<div className='grid grid-cols-2 gap-4'>
{images.map((image, index) => (
<div key={index} className='group relative overflow-hidden rounded-md border'>
<div className='relative aspect-video'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
className='object-cover'
/>
<div
className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeImage(index)}
>
<X className='h-6 w-6 text-white' />
</div>
</div>
<div className='truncate bg-muted/50 p-2 text-xs'>{image.name}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'>
<Button variant='outline' onClick={onClose} type='button'>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting || isProcessing}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</div>
</form>
)
}

View File

@@ -1,10 +1,55 @@
'use client'
import { useEffect } from 'react'
import { X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import imageCompression from 'browser-image-compression'
import { AlertCircle, CheckCircle2, Upload, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { HelpForm } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/components/help-form/help-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('HelpModal')
// Define form schema
const formSchema = z.object({
email: z.string().email('Please enter a valid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other'], {
required_error: 'Please select a request type',
}),
})
type FormValues = z.infer<typeof formSchema>
// Increased maximum upload size to 20MB
const MAX_FILE_SIZE = 20 * 1024 * 1024
// Target size after compression (2MB)
const TARGET_SIZE_MB = 2
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
interface ImageWithPreview extends File {
preview: string
}
interface HelpModalProps {
open: boolean
@@ -12,6 +57,34 @@ interface HelpModalProps {
}
export function HelpModal({ open, onOpenChange }: HelpModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null)
const [errorMessage, setErrorMessage] = useState('')
const [images, setImages] = useState<ImageWithPreview[]>([])
const [imageError, setImageError] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
subject: '',
message: '',
type: 'bug', // Set default value to 'bug'
},
mode: 'onChange',
})
// Listen for the custom event to open the help modal
useEffect(() => {
const handleOpenHelp = (event: CustomEvent) => {
@@ -27,31 +100,420 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
}
}, [onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[700px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Help & Support</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
// Set default value for type on component mount
useEffect(() => {
setValue('type', 'bug')
}, [setValue])
<div className='flex flex-1 flex-col overflow-hidden'>
<HelpForm onClose={() => onOpenChange(false)} />
// Scroll to top when success message appears
useEffect(() => {
if (submitStatus === 'success' && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [submitStatus])
// Clean up object URLs when component unmounts
useEffect(() => {
return () => {
images.forEach((image) => URL.revokeObjectURL(image.preview))
}
}, [images])
// Scroll to bottom when images are added
useEffect(() => {
if (images.length > 0 && scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current
setTimeout(() => {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
}, 100) // Small delay to ensure DOM has updated
}
}, [images.length])
const compressImage = async (file: File): Promise<File> => {
// Skip compression for small files or GIFs (which don't compress well)
if (file.size < TARGET_SIZE_MB * 1024 * 1024 || file.type === 'image/gif') {
return file
}
const options = {
maxSizeMB: TARGET_SIZE_MB,
maxWidthOrHeight: 1920,
useWebWorker: true,
fileType: file.type,
// Ensure we maintain proper file naming and MIME types
initialQuality: 0.8,
alwaysKeepResolution: true,
}
try {
const compressedFile = await imageCompression(file, options)
// Create a new File object with the original name and type to ensure compatibility
return new File([compressedFile], file.name, {
type: file.type,
lastModified: Date.now(),
})
} catch (error) {
logger.warn('Image compression failed, using original file:', { error })
return file
}
}
const processFiles = async (files: FileList | File[]) => {
setImageError(null)
if (!files || files.length === 0) return
setIsProcessing(true)
try {
const newImages: ImageWithPreview[] = []
let hasError = false
for (const file of Array.from(files)) {
// Check file size
if (file.size > MAX_FILE_SIZE) {
setImageError(`File ${file.name} is too large. Maximum size is 20MB.`)
hasError = true
continue
}
// Check file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setImageError(
`File ${file.name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.`
)
hasError = true
continue
}
// Compress the image (behind the scenes)
const compressedFile = await compressImage(file)
// Create preview URL
const imageWithPreview = Object.assign(compressedFile, {
preview: URL.createObjectURL(compressedFile),
}) as ImageWithPreview
newImages.push(imageWithPreview)
}
if (!hasError && newImages.length > 0) {
setImages((prev) => [...prev, ...newImages])
}
} catch (error) {
logger.error('Error processing images:', { error })
setImageError('An error occurred while processing images. Please try again.')
} finally {
setIsProcessing(false)
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
// Update the existing handleFileChange function to use processFiles
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
await processFiles(e.target.files)
}
}
// Handle drag events
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const removeImage = (index: number) => {
setImages((prev) => {
// Revoke the URL to avoid memory leaks
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
// Create FormData to handle file uploads
const formData = new FormData()
// Add form fields
formData.append('email', data.email)
formData.append('subject', data.subject)
formData.append('message', data.message)
formData.append('type', data.type)
// Add images
images.forEach((image, index) => {
formData.append(`image_${index}`, image)
})
const response = await fetch('/api/help', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to submit help request')
}
setSubmitStatus('success')
reset()
// Clean up image previews
images.forEach((image) => URL.revokeObjectURL(image.preview))
setImages([])
} catch (error) {
logger.error('Error submitting help request:', { error })
setSubmitStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'An unknown error occurred')
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
onOpenChange(false)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='flex h-[75vh] max-h-[75vh] flex-col gap-0 p-0 sm:max-w-[700px]'>
<AlertDialogHeader className='flex-shrink-0 px-6 py-5'>
<AlertDialogTitle className='font-medium text-lg'>Help & Support</AlertDialogTitle>
</AlertDialogHeader>
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
{/* Scrollable Content */}
<div
ref={scrollContainerRef}
className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'
>
<div className='px-6'>
{submitStatus === 'success' ? (
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
<div className='flex items-start gap-4 py-1'>
<div className='mt-[-1.5px] flex-shrink-0'>
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
</div>
<div className='mr-4 flex-1 space-y-2'>
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
<span className='font-medium text-green-600 dark:text-green-400'>
Success
</span>
</AlertTitle>
<AlertDescription className='text-green-600 dark:text-green-400'>
Your request has been submitted successfully. We'll get back to you soon.
</AlertDescription>
</div>
</div>
</Alert>
) : submitStatus === 'error' ? (
<Alert variant='destructive' className='mb-6'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{errorMessage ||
'There was an error submitting your request. Please try again.'}
</AlertDescription>
</Alert>
) : null}
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='type'>Request</Label>
<Select
defaultValue='bug'
onValueChange={(value) => setValue('type', value as any)}
>
<SelectTrigger
id='type'
className={`h-9 rounded-[8px] ${errors.type ? 'border-red-500' : ''}`}
>
<SelectValue placeholder='Select a request type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='bug'>Bug Report</SelectItem>
<SelectItem value='feedback'>Feedback</SelectItem>
<SelectItem value='feature_request'>Feature Request</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent>
</Select>
{errors.type && (
<p className='mt-1 text-red-500 text-sm'>{errors.type.message}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
placeholder='your.email@example.com'
{...register('email')}
className={`h-9 rounded-[8px] ${errors.email ? 'border-red-500' : ''}`}
/>
{errors.email && (
<p className='mt-1 text-red-500 text-sm'>{errors.email.message}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='subject'>Subject</Label>
<Input
id='subject'
placeholder='Brief description of your request'
{...register('subject')}
className={`h-9 rounded-[8px] ${errors.subject ? 'border-red-500' : ''}`}
/>
{errors.subject && (
<p className='mt-1 text-red-500 text-sm'>{errors.subject.message}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='message'>Message</Label>
<Textarea
id='message'
placeholder='Please provide details about your request...'
rows={5}
{...register('message')}
className={`rounded-[8px] ${errors.message ? 'border-red-500' : ''}`}
/>
{errors.message && (
<p className='mt-1 text-red-500 text-sm'>{errors.message.message}</p>
)}
</div>
{/* Image Upload Section */}
<div className='mt-6 space-y-2'>
<Label>Attach Images (Optional)</Label>
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex items-center gap-4 ${
isDragging ? 'rounded-md bg-primary/5 p-2' : ''
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileChange}
className='hidden'
multiple
/>
<Button
type='button'
variant='outline'
onClick={() => fileInputRef.current?.click()}
className='flex h-9 items-center justify-center gap-2 rounded-[8px]'
>
<Upload className='h-4 w-4' />
Upload Images
</Button>
<p className='text-muted-foreground text-xs'>
Drop images here or click to upload. Max 20MB per image.
</p>
</div>
{imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>}
{isProcessing && (
<p className='text-muted-foreground text-sm'>Processing images...</p>
)}
</div>
{/* Image Preview Section */}
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<div className='grid grid-cols-2 gap-4'>
{images.map((image, index) => (
<div
key={index}
className='group relative overflow-hidden rounded-md border'
>
<div className='relative aspect-video'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
className='object-cover'
/>
<div
className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeImage(index)}
>
<X className='h-6 w-6 text-white' />
</div>
</div>
<div className='truncate bg-muted/50 p-2 text-xs'>{image.name}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Overlay Footer */}
<div className='absolute inset-x-0 bottom-0 bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<Button
variant='outline'
onClick={handleClose}
type='button'
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button
type='submit'
disabled={isSubmitting || isProcessing}
className='h-9 rounded-[8px]'
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,5 +1,4 @@
export { CreateMenu } from './create-menu'
export { FolderContextMenu } from './folder-context-menu/folder-context-menu'
export { CreateMenu } from './create-menu/create-menu'
export { FolderTree } from './folder-tree/folder-tree'
export { HelpModal } from './help-modal/help-modal'
export { LogsFilters } from './logs-filters/logs-filters'

View File

@@ -47,7 +47,7 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
'relative px-3 py-1.5 font-medium text-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
disabled && 'cursor-not-allowed opacity-50',
value === option.value
? 'z-10 bg-primary text-primary-foreground shadow-sm'
? 'z-10 bg-primary text-primary-foreground'
: 'text-muted-foreground hover:z-20 hover:bg-muted/50 hover:text-foreground',
index > 0 && 'border-input border-l'
)}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockConfig } from '@/blocks/types'
export type ToolbarBlockProps = {
@@ -40,21 +40,21 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
'group flex h-9 items-center gap-[10px] rounded-[8px] p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
: 'cursor-pointer hover:bg-muted active:cursor-grabbing'
)}
>
<div
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
style={{ backgroundColor: config.bgColor }}
>
<config.icon
className={cn(
'text-white transition-transform duration-200',
!disabled && 'group-hover:scale-110',
'h-[14px] w-[14px]'
'!h-4 !w-4'
)}
/>
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-config'
type LoopToolbarItemProps = {
@@ -49,14 +49,14 @@ export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemPro
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
'group flex h-8 items-center gap-[10px] rounded-[8px] p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
: 'cursor-pointer hover:bg-muted active:cursor-grabbing'
)}
>
<div
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
style={{ backgroundColor: LoopTool.bgColor }}
>
<LoopTool.icon

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-config'
type ParallelToolbarItemProps = {
@@ -49,14 +49,14 @@ export default function ParallelToolbarItem({ disabled = false }: ParallelToolba
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
'group flex h-8 items-center gap-[10px] rounded-[8px] p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
: 'cursor-pointer hover:bg-muted active:cursor-grabbing'
)}
>
<div
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
style={{ backgroundColor: ParallelTool.bgColor }}
>
<ParallelTool.icon

View File

@@ -101,13 +101,13 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
<div className='flex h-full flex-col'>
{/* Search */}
<div className='flex-shrink-0 p-2'>
<div className='flex h-9 items-center gap-2 rounded-[10px] border bg-background pr-2 pl-3'>
<div className='flex h-9 items-center gap-2 rounded-[8px] border bg-background pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search blocks...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='h-6 flex-1 border-0 bg-transparent px-0 font-normal text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -117,7 +117,7 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
</div>
{/* Content */}
<ScrollArea className='flex-1 px-2' hideScrollbar={true}>
<ScrollArea className='flex-1 px-2 pb-[0.26px]' hideScrollbar={true}>
<div className='space-y-1 pb-2'>
{/* Regular Blocks Section */}
{regularBlocks.map((block) => (

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface WorkflowContextMenuProps {
onStartEdit?: () => void

View File

@@ -12,10 +12,9 @@ interface WorkflowItemProps {
workflow: WorkflowMetadata
active: boolean
isMarketplace?: boolean
isCollapsed?: boolean
}
function WorkflowItem({ workflow, active, isMarketplace, isCollapsed }: WorkflowItemProps) {
function WorkflowItem({ workflow, active, isMarketplace }: WorkflowItemProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -24,23 +23,17 @@ function WorkflowItem({ workflow, active, isMarketplace, isCollapsed }: Workflow
href={`/workspace/${workspaceId}/w/${workflow.id}`}
className={clsx(
'flex items-center rounded-md px-2 py-1.5 font-medium text-sm',
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
isCollapsed && 'mx-auto h-8 w-8 justify-center'
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50'
)}
>
<div
className={clsx(
'flex-shrink-0 rounded',
isCollapsed ? 'h-[14px] w-[14px]' : 'mr-2 h-[14px] w-[14px]'
)}
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflow.color }}
/>
{!isCollapsed && (
<span className='truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
)}
<span className='truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
</Link>
)
}
@@ -48,14 +41,12 @@ function WorkflowItem({ workflow, active, isMarketplace, isCollapsed }: Workflow
interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[]
marketplaceWorkflows: WorkflowMetadata[]
isCollapsed?: boolean
isLoading?: boolean
}
export function WorkflowList({
regularWorkflows,
marketplaceWorkflows,
isCollapsed = false,
isLoading = false,
}: WorkflowListProps) {
const pathname = usePathname()
@@ -70,21 +61,13 @@ export function WorkflowList({
.map((_, i) => (
<div
key={`skeleton-${i}`}
className={`mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 ${
isCollapsed ? 'justify-center' : ''
}`}
className='mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5'
>
{isCollapsed ? (
<Skeleton className='h-[14px] w-[14px] rounded-md' />
) : (
<>
<Skeleton className='h-[14px] w-[14px] rounded-md' />
<Skeleton className='h-4 w-20' />
</>
)}
<Skeleton className='h-[14px] w-[14px] rounded-md' />
<Skeleton className='h-4 w-20' />
</div>
))
}, [isCollapsed])
}, [])
// Only show empty state when not loading and user is logged in
const showEmptyState =
@@ -106,34 +89,26 @@ export function WorkflowList({
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isCollapsed={isCollapsed}
/>
))}
{/* Marketplace Temp Workflows (if any) */}
{marketplaceWorkflows.length > 0 && (
<div className='mt-2 border-border/30 border-t pt-2'>
<h3
className={`mb-1 px-2 font-medium text-muted-foreground text-xs ${
isCollapsed ? 'text-center' : ''
}`}
>
{isCollapsed ? '' : 'Marketplace'}
</h3>
<h3 className='mb-1 px-2 font-medium text-muted-foreground text-xs'>Marketplace</h3>
{marketplaceWorkflows.map((workflow) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isMarketplace
isCollapsed={isCollapsed}
/>
))}
</div>
)}
{/* Empty state */}
{showEmptyState && !isCollapsed && (
{showEmptyState && (
<div className='px-2 py-1.5 text-muted-foreground text-xs'>
No workflows in {workspaceId ? 'this workspace' : 'your account'}. Create one to get
started.

View File

@@ -1,17 +1,11 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronUp, PanelLeft } from 'lucide-react'
import Link from 'next/link'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
const logger = createLogger('WorkspaceHeader')
/**
* Workspace entity interface
@@ -33,7 +27,6 @@ interface WorkspaceHeaderProps {
onToggleSidebar: () => void
activeWorkspace: Workspace | null
isWorkspacesLoading: boolean
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
}
/**
@@ -47,27 +40,16 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
onToggleSidebar,
activeWorkspace,
isWorkspacesLoading,
updateWorkspaceName,
}) => {
// External hooks
const { data: sessionData } = useSession()
const userPermissions = useUserPermissionsContext()
const [isClientLoading, setIsClientLoading] = useState(true)
const [isEditingName, setIsEditingName] = useState(false)
const [editingName, setEditingName] = useState('')
// Refs
const editInputRef = useRef<HTMLInputElement>(null)
// Computed values
const userName = useMemo(
() => sessionData?.user?.name || sessionData?.user?.email || 'User',
[sessionData?.user?.name, sessionData?.user?.email]
)
const workspaceUrl = useMemo(
() => (activeWorkspace ? `/workspace/${activeWorkspace.id}/w` : '/workspace'),
[activeWorkspace]
)
const displayName = useMemo(
() => activeWorkspace?.name || `${userName}'s Workspace`,
@@ -79,207 +61,98 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
setIsClientLoading(false)
}, [])
// Focus input when editing starts
useEffect(() => {
if (isEditingName && editInputRef.current) {
editInputRef.current.focus()
editInputRef.current.select()
}
}, [isEditingName])
// Handle header click to toggle workspace selector
const handleHeaderClick = useCallback(() => {
onToggleWorkspaceSelector()
}, [onToggleWorkspaceSelector])
// Handle workspace name click
const handleWorkspaceNameClick = useCallback(() => {
// Only allow admins to rename workspace
if (!userPermissions.canAdmin) {
return
}
setEditingName(displayName)
setIsEditingName(true)
}, [displayName, userPermissions.canAdmin])
// Handle workspace name editing actions
const handleEditingAction = useCallback(
(action: 'save' | 'cancel') => {
switch (action) {
case 'save': {
// Exit edit mode immediately, save in background
setIsEditingName(false)
const trimmedName = editingName.trim()
if (activeWorkspace && trimmedName !== '' && trimmedName !== activeWorkspace.name) {
updateWorkspaceName(activeWorkspace.id, trimmedName).catch((error) => {
logger.error('Failed to update workspace name:', error)
})
}
break
}
case 'cancel': {
// Cancel without saving
setIsEditingName(false)
setEditingName('')
break
}
}
// Handle sidebar toggle click
const handleSidebarToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation() // Prevent header click
onToggleSidebar()
},
[activeWorkspace, editingName, updateWorkspaceName]
[onToggleSidebar]
)
// Handle keyboard interactions
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleEditingAction('save')
} else if (e.key === 'Escape') {
handleEditingAction('cancel')
}
},
[handleEditingAction]
)
// Handle click away - immediate exit with background save
const handleInputBlur = useCallback(() => {
handleEditingAction('save')
}, [handleEditingAction])
// Render loading state
const renderLoadingState = () => (
<>
{/* Icon */}
<div className='flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'>
<AgentIcon className='h-4 w-4 text-white' />
{/* Loading workspace name - matches actual layout */}
<div className='flex min-w-0 flex-1 items-center pl-1'>
<Skeleton className='h-4 w-24' />
</div>
{/* Loading workspace name and chevron container */}
<div className='flex min-w-0 flex-1 items-center'>
<div className='min-w-0 flex-1 p-1'>
<Skeleton className='h-4 w-24' />
</div>
{/* Chevron */}
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
disabled
>
{isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
{/* Chevron - actual element, not skeleton */}
<div className='flex h-5 w-5 items-center justify-center text-muted-foreground'>
{!isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</div>
{/* Toggle Sidebar - with gap-2 max from chevron */}
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
disabled
>
<PanelLeft className='h-4 w-4' />
</Button>
</div>
{/* Toggle Sidebar - actual element, not skeleton */}
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
disabled
>
<PanelLeft className='h-4 w-4' />
</Button>
</>
)
// Render workspace info
const renderWorkspaceInfo = () => (
<>
{/* Icon - separate from hover area */}
<Link
href={workspaceUrl}
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
>
<AgentIcon className='h-4 w-4 text-white transition-all group-hover:scale-105' />
</Link>
{/* Workspace Name - Display only */}
<div className='flex min-w-0 flex-1 items-center pl-1'>
<span
className='truncate font-medium text-sm leading-none'
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
>
{displayName}
</span>
</div>
{/* Workspace Name and Chevron Container */}
<div className='flex min-w-0 flex-1 items-center'>
{/* Workspace Name - Editable */}
<div className={`flex min-w-0 items-center p-1 ${isEditingName ? 'flex-1' : ''}`}>
{isEditingName ? (
<input
ref={editInputRef}
type='text'
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
className='m-0 h-auto w-full resize-none truncate border-0 bg-transparent p-0 font-medium text-sm leading-none outline-none'
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={handleWorkspaceNameClick}
className={`truncate font-medium text-sm leading-none transition-all ${
userPermissions.canAdmin
? 'cursor-pointer hover:brightness-75 dark:hover:brightness-125'
: 'cursor-default'
}`}
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
>
{displayName}
</div>
</TooltipTrigger>
{!userPermissions.canAdmin && (
<TooltipContent side='bottom'>
Admin permissions required to rename workspace
</TooltipContent>
)}
</Tooltip>
)}
</div>
{/* Chevron - Next to name, hidden in edit mode */}
{!isEditingName && (
<Button
variant='ghost'
size='icon'
onClick={onToggleWorkspaceSelector}
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
>
{isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
{/* Chevron - Display only */}
<div className='flex h-5 w-5 items-center justify-center text-muted-foreground'>
{!isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</div>
{/* Toggle Sidebar - with gap-2 max from chevron */}
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onToggleSidebar}
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
>
<PanelLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>Toggle sidebar</TooltipContent>
</Tooltip>
</div>
{/* Toggle Sidebar - with gap-1 from chevron */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleSidebarToggle}
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
>
<PanelLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>Toggle sidebar</TooltipContent>
</Tooltip>
</>
)
// Main render - using h-12 to match control bar height
return (
<div className='h-12 rounded-[14px] border bg-card shadow-xs'>
<div className='flex h-full items-center gap-1 px-3'>
<div className='h-12 rounded-[10px] border bg-background shadow-xs'>
<div
className='flex h-full cursor-pointer items-center gap-1 rounded-[10px] pr-[10px] pl-3 transition-colors hover:bg-muted/50'
onClick={handleHeaderClick}
>
{isClientLoading || isWorkspacesLoading ? renderLoadingState() : renderWorkspaceInfo()}
</div>
</div>

View File

@@ -1,13 +1,21 @@
'use client'
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { HelpCircle, Loader2, Trash2, X } from 'lucide-react'
import { Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { validateAndNormalizeEmail } from '@/lib/email/utils'
import { createLogger } from '@/lib/logs/console/logger'
@@ -16,7 +24,7 @@ import { cn } from '@/lib/utils'
import {
useUserPermissionsContext,
useWorkspacePermissionsContext,
} from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
} from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import { API_ENDPOINTS } from '@/stores/constants'
@@ -26,6 +34,7 @@ interface InviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onInviteMember?: (email: string) => void
workspaceName?: string
}
interface EmailTagProps {
@@ -33,6 +42,7 @@ interface EmailTagProps {
onRemove: () => void
disabled?: boolean
isInvalid?: boolean
isSent?: boolean
}
interface UserPermissions {
@@ -67,16 +77,27 @@ interface PendingInvitation {
createdAt: string
}
const EmailTag = React.memo<EmailTagProps>(({ email, onRemove, disabled, isInvalid }) => (
const EmailTag = React.memo<EmailTagProps>(({ email, onRemove, disabled, isInvalid, isSent }) => (
<div
className={`flex items-center ${isInvalid ? 'border-red-200 bg-red-50 text-red-700' : 'border-gray-200 bg-gray-100 text-slate-700'} my-0 ml-0 w-auto gap-1 rounded-md border px-2 py-0.5 text-sm`}
className={cn(
'flex w-auto items-center gap-1 rounded-[8px] border px-2 py-0.5 text-sm',
isInvalid
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'border bg-muted text-muted-foreground'
)}
>
<span className='max-w-[180px] truncate'>{email}</span>
{!disabled && (
<span className='max-w-[120px] truncate'>{email}</span>
{isSent && <span className='text-muted-foreground text-xs'>sent</span>}
{!disabled && !isSent && (
<button
type='button'
onClick={onRemove}
className={`${isInvalid ? 'text-red-400 hover:text-red-600' : 'text-gray-400 hover:text-gray-600'} flex-shrink-0 focus:outline-none`}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-red-400 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label={`Remove ${email}`}
>
<X className='h-3 w-3' />
@@ -106,7 +127,9 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
)
return (
<div className={cn('inline-flex rounded-md border border-input bg-background', className)}>
<div
className={cn('inline-flex rounded-[12px] border border-input bg-background', className)}
>
{permissionOptions.map((option, index) => (
<button
key={option.value}
@@ -114,11 +137,11 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
onClick={() => !disabled && onChange(option.value)}
disabled={disabled}
className={cn(
'px-3 py-1.5 font-medium text-sm transition-colors focus:outline-none',
'first:rounded-l-md last:rounded-r-md',
'px-2.5 py-1.5 font-medium text-xs transition-colors focus:outline-none',
'first:rounded-l-[11px] last:rounded-r-[11px]',
disabled && 'cursor-not-allowed opacity-50',
value === option.value
? 'bg-primary text-primary-foreground shadow-sm'
? 'bg-foreground text-background'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
index > 0 && 'border-input border-l'
)}
@@ -134,45 +157,13 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
PermissionSelector.displayName = 'PermissionSelector'
const PermissionsTableSkeleton = React.memo(() => (
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
type='button'
>
<HelpCircle className='h-4 w-4' />
<span className='sr-only'>Member permissions help</span>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[320px]'>
<p className='text-sm'>Loading permissions...</p>
</TooltipContent>
</Tooltip>
</div>
<div className='rounded-md border'>
<div className='divide-y'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex items-center justify-between p-4'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<Skeleton className='h-4 w-48' />
{i === 1 && <Skeleton className='h-5 w-12 rounded-md' />}
</div>
<div className='mt-1 flex items-center gap-2'>
{i > 0 && <Skeleton className='h-5 w-16 rounded-md' />}
</div>
</div>
<div className='flex-shrink-0'>
<Skeleton className='h-9 w-32 rounded-md' />
</div>
</div>
))}
</div>
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-2 py-2'>
{/* Email skeleton - matches the actual email span dimensions */}
<Skeleton className='h-5 w-40' />
{/* Permission selector skeleton - matches PermissionSelector exact height */}
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
</div>
</div>
))
@@ -180,16 +171,8 @@ const PermissionsTableSkeleton = React.memo(() => (
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'
const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string => {
switch (status) {
case 'sent':
return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'member':
return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
case 'modified':
return 'inline-flex items-center rounded-md bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
default:
return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
// Use consistent gray styling for all statuses to align with modal design
return 'inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
const PermissionsTable = ({
@@ -273,7 +256,7 @@ const PermissionsTable = ({
return (
<div className='space-y-4'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<div className='rounded-md border bg-card'>
<div className='rounded-[8px] border bg-card'>
<div className='flex items-center justify-center py-12'>
<div className='flex items-center space-x-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
@@ -281,11 +264,9 @@ const PermissionsTable = ({
</div>
</div>
</div>
<div className='flex min-h-[2rem] items-start'>
<p className='text-muted-foreground text-xs'>
Please wait while we update the permissions.
</p>
</div>
<p className='flex min-h-[2rem] items-start text-muted-foreground text-xs'>
Please wait while we update the permissions.
</p>
</div>
)
}
@@ -293,147 +274,103 @@ const PermissionsTable = ({
const currentUserIsAdmin = userPerms.canAdmin
return (
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h3 className='font-medium text-sm'>Member Permissions</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
type='button'
>
<HelpCircle className='h-4 w-4' />
<span className='sr-only'>Member permissions help</span>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[320px]'>
<div className='space-y-2'>
{userPerms.isLoading || permissionsLoading ? (
<p className='text-sm'>Loading permissions...</p>
) : !currentUserIsAdmin ? (
<p className='text-sm'>
Only administrators can invite new members and modify permissions.
</p>
) : (
<div className='space-y-1'>
<p className='text-sm'>Admin grants all permissions automatically.</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</div>
<div className='rounded-md border'>
{allUsers.length > 0 && (
<div className='divide-y'>
{allUsers.map((user) => {
const isCurrentUser = user.isCurrentUser === true
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
const isPendingInvitation = user.isPendingInvitation === true
const userIdentifier = user.userId || user.email
// Check if current permission is different from original permission
const originalPermission = workspacePermissions?.users?.find(
(eu) => eu.userId === user.userId
)?.permissionType
const currentPermission =
existingUserPermissionChanges[userIdentifier]?.permissionType ?? user.permissionType
const hasChanges = originalPermission && currentPermission !== originalPermission
// Check if user is in workspace permissions directly
const isWorkspaceMember = workspacePermissions?.users?.some(
(eu) => eu.email === user.email && eu.userId
)
const canShowRemoveButton =
isWorkspaceMember &&
!isCurrentUser &&
!isPendingInvitation &&
currentUserIsAdmin &&
user.userId
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
{allUsers.length > 0 && (
<div>
{allUsers.map((user) => {
const isCurrentUser = user.isCurrentUser === true
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
const isPendingInvitation = user.isPendingInvitation === true
const userIdentifier = user.userId || user.email
// Check if current permission is different from original permission
const originalPermission = workspacePermissions?.users?.find(
(eu) => eu.userId === user.userId
)?.permissionType
const currentPermission =
existingUserPermissionChanges[userIdentifier]?.permissionType ?? user.permissionType
const hasChanges = originalPermission && currentPermission !== originalPermission
// Check if user is in workspace permissions directly
const isWorkspaceMember = workspacePermissions?.users?.some(
(eu) => eu.email === user.email && eu.userId
)
const canShowRemoveButton =
isWorkspaceMember &&
!isCurrentUser &&
!isPendingInvitation &&
currentUserIsAdmin &&
user.userId
const uniqueKey = user.userId
? `existing-${user.userId}`
: isPendingInvitation
? `pending-${user.email}`
: `new-${user.email}`
const uniqueKey = user.userId
? `existing-${user.userId}`
: isPendingInvitation
? `pending-${user.email}`
: `new-${user.email}`
return (
<div key={uniqueKey} className='flex items-center justify-between p-4'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className={getStatusBadgeStyles('sent')}>Sent</span>
)}
{/* Show remove button for existing workspace members (not current user, not pending) */}
{canShowRemoveButton && onRemoveMember && (
<Button
variant='ghost'
size='sm'
onClick={() => onRemoveMember(user.userId!, user.email)}
disabled={disabled || isSaving}
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
title={`Remove ${user.email} from workspace`}
>
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Remove {user.email}</span>
</Button>
)}
{/* Show remove button for pending invitations */}
{isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onRemoveInvitation && (
<Button
variant='ghost'
size='sm'
onClick={() => onRemoveInvitation(user.invitationId!, user.email)}
disabled={disabled || isSaving}
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
title={`Cancel invitation for ${user.email}`}
>
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Cancel invitation for {user.email}</span>
</Button>
)}
</div>
<div className='mt-1 flex items-center gap-2'>
{isExistingUser && !isCurrentUser && (
<span className={getStatusBadgeStyles('member')}>Member</span>
)}
{hasChanges && (
<span className={getStatusBadgeStyles('modified')}>Modified</span>
)}
</div>
</div>
<div className='flex-shrink-0'>
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) =>
onPermissionChange(userIdentifier, newPermission)
return (
<div key={uniqueKey} className='flex items-center justify-between gap-2 py-2'>
{/* Email - truncated if needed */}
<span className='min-w-0 flex-1 truncate font-medium text-card-foreground text-sm'>
{user.email}
</span>
{/* Permission selector */}
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) => onPermissionChange(userIdentifier, newPermission)}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
className='w-auto flex-shrink-0'
/>
{/* X button - styled like workflow-item.tsx */}
{((canShowRemoveButton && onRemoveMember) ||
(isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onRemoveInvitation)) && (
<Button
variant='ghost'
size='icon'
onClick={() => {
if (canShowRemoveButton && onRemoveMember) {
onRemoveMember(user.userId!, user.email)
} else if (isPendingInvitation && user.invitationId && onRemoveInvitation) {
onRemoveInvitation(user.invitationId, user.email)
}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
className='w-auto'
/>
</div>
</div>
)
})}
</div>
)}
</div>
}}
disabled={disabled || isSaving}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
title={
isPendingInvitation
? `Cancel invitation for ${user.email}`
: `Remove ${user.email} from workspace`
}
>
<X className='h-3.5 w-3.5' />
<span className='sr-only'>
{isPendingInvitation
? `Cancel invitation for ${user.email}`
: `Remove ${user.email}`}
</span>
</Button>
)}
</div>
)
})}
</div>
)}
</div>
)
}
export function InviteModal({ open, onOpenChange }: InviteModalProps) {
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState<string[]>([])
const [sentEmails, setSentEmails] = useState<string[]>([])
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
@@ -467,7 +404,8 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
} = useWorkspacePermissionsContext()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const hasNewInvites = emails.length > 0 || inputValue.trim()
const hasNewInvites =
emails.filter((email) => !sentEmails.includes(email)).length > 0 || inputValue.trim()
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
@@ -899,29 +837,16 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
fetchPendingInvitations()
setInputValue('')
// Track which emails were successfully sent
const successfulEmails = emails.filter((email, index) => results[index])
setSentEmails((prev) => [...prev, ...successfulEmails])
if (failedInvites.length > 0) {
setEmails(failedInvites)
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmails([])
setUserPermissions([])
setSuccessMessage(
successCount === 1
? 'Invitation sent successfully!'
: `${successCount} invitations sent successfully!`
)
setTimeout(() => {
onOpenChange(false)
}, 1500)
}
setInvalidEmails([])
setShowSent(true)
setTimeout(() => {
setShowSent(false)
}, 4000)
}
} catch (err) {
logger.error('Error inviting members:', err)
@@ -948,6 +873,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
// Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setSentEmails([])
setInvalidEmails([])
setUserPermissions([])
setPendingInvitations([])
@@ -965,7 +891,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
}, [])
return (
<Dialog
<AlertDialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen) {
@@ -974,248 +900,183 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
onOpenChange(newOpen)
}}
>
<DialogContent
className='flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Invite Members to Workspace</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<AlertDialogContent className='flex max-h-[80vh] flex-col gap-0 sm:max-w-[560px]'>
<AlertDialogHeader>
<AlertDialogTitle>Invite members to {workspaceName || 'Workspace'}</AlertDialogTitle>
</AlertDialogHeader>
<div className='max-h-[80vh] overflow-y-auto px-6 pt-4 pb-6'>
<form onSubmit={handleSubmit}>
<div className='space-y-4'>
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<label htmlFor='emails' className='font-medium text-sm'>
Email Addresses
</label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
type='button'
>
<HelpCircle className='h-4 w-4' />
<span className='sr-only'>Email addresses help</span>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[280px]'>
<p className='text-sm'>
Press Enter, comma, or space after each email address to add it to the list.
</p>
</TooltipContent>
</Tooltip>
</div>
<div
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border px-3 py-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'
)}
>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => removeInvalidEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => removeEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
/>
))}
<Input
id='emails'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter email addresses'
}
className={cn(
'h-7 min-w-[180px] flex-1 border-none py-1 focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-0'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
/>
</div>
{(errorMessage || successMessage) && (
<p
className={cn(
'mt-1 text-xs',
errorMessage ? 'text-destructive' : 'text-green-600'
)}
>
{errorMessage || successMessage}
</p>
<form onSubmit={handleSubmit} className='mt-5'>
<div className='space-y-2'>
<label htmlFor='emails' className='font-medium text-sm'>
Email Addresses
</label>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => removeInvalidEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => removeEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isSent={sentEmails.includes(email)}
/>
))}
<Input
id='emails'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
)}
</div>
<PermissionsTable
userPermissions={userPermissions}
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
/>
<div className='flex justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
className='gap-2 font-medium'
>
Restore Changes
</Button>
<Button
type='button'
variant='outline'
size='sm'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='gap-2 font-medium'
>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
Save Changes
</Button>
</div>
)}
<Button
type='submit'
size='sm'
disabled={
!userPerms.canAdmin ||
!hasNewInvites ||
isSubmitting ||
isSaving ||
!workspaceId
}
className={cn(
'ml-auto gap-2 font-medium',
'bg-[#802FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{!userPerms.canAdmin
? 'Admin Access Required'
: showSent
? 'Sent!'
: 'Send Invitations'}
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
{errorMessage && <p className='mt-1 text-destructive text-xs'>{errorMessage}</p>}
</div>
{/* Line separator */}
<div className='mt-6 mb-4 border-t' />
<PermissionsTable
userPermissions={userPermissions}
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
/>
</form>
{/* Consistent spacing below user list to match spacing above */}
<div className='mb-4' />
<AlertDialogFooter className='flex justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
Restore Changes
</Button>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
Save Changes
</Button>
</>
)}
<Button
type='submit'
disabled={!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId}
className={cn(
'ml-auto flex h-9 items-center justify-center gap-2 rounded-[8px] px-4 py-2 font-medium transition-all duration-200',
'bg-[#701FFC] text-white shadow-[0_0_0_0_#701FFC] hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(112,31,252,0.15)] disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none'
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{!userPerms.canAdmin ? 'Admin Access Required' : 'Send Invite'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Remove Member</DialogTitle>
</DialogHeader>
<div className='py-4'>
<p className='text-muted-foreground text-sm'>
<AlertDialog open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove{' '}
<span className='font-medium text-foreground'>{memberToRemove?.email}</span> from this
workspace? This action cannot be undone.
</p>
</div>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
workspace?{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={handleRemoveMemberCancel}
disabled={isRemovingMember}
>
Cancel
</Button>
<Button
variant='destructive'
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemoveMemberConfirm}
disabled={isRemovingMember}
className='gap-2'
className='h-9 w-full gap-2 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{isRemovingMember && <Loader2 className='h-4 w-4 animate-spin' />}
Remove Member
</Button>
</div>
</DialogContent>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Remove Invitation Confirmation Dialog */}
<Dialog open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Cancel Invitation</DialogTitle>
</DialogHeader>
<div className='py-4'>
<p className='text-muted-foreground text-sm'>
<AlertDialog open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Invitation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel the invitation for{' '}
<span className='font-medium text-foreground'>{invitationToRemove?.email}</span>? This
action cannot be undone.
</p>
</div>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
<span className='font-medium text-foreground'>{invitationToRemove?.email}</span>?{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={handleRemoveInvitationCancel}
disabled={isRemovingInvitation}
>
Cancel
</Button>
<Button
variant='destructive'
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemoveInvitationConfirm}
disabled={isRemovingInvitation}
className='gap-2'
className='h-9 w-full gap-2 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{isRemovingInvitation && <Loader2 className='h-4 w-4 animate-spin' />}
Cancel Invitation
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialog>
)
}

View File

@@ -1,166 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('InvitesSent')
type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired'
type Invitation = {
id: string
email: string
status: InvitationStatus
createdAt: string
}
const getInvitationStatusStyles = (status: InvitationStatus) => {
switch (status) {
case 'accepted':
return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
case 'pending':
return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'rejected':
return 'inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300'
case 'expired':
return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
default:
return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
}
const formatInvitationStatus = (status: InvitationStatus): string => {
return status.charAt(0).toUpperCase() + status.slice(1)
}
export function InvitesSent() {
const [invitations, setInvitations] = useState<Invitation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const params = useParams()
const workspaceId = params.workspaceId as string
useEffect(() => {
async function fetchInvitations() {
if (!workspaceId) {
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/workspaces/invitations')
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(errorData.error || `Failed to fetch invitations (${response.status})`)
}
const data = await response.json()
const filteredInvitations = (data.invitations || []).filter(
(inv: Invitation) => inv.status === 'pending'
)
setInvitations(filteredInvitations)
} catch (err) {
logger.error('Error fetching invitations:', err)
const errorMessage = err instanceof Error ? err.message : 'Failed to load invitations'
setError(errorMessage)
} finally {
setIsLoading(false)
}
}
fetchInvitations()
}, [workspaceId])
const TableSkeleton = () => (
<div className='space-y-2'>
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className='flex items-center justify-between'>
<Skeleton className='h-6 w-3/4' />
<Skeleton className='h-6 w-16' />
</div>
))}
</div>
)
if (error) {
return (
<div className='mt-4'>
<h3 className='mb-2 font-medium text-sm'>Sent Invitations</h3>
<div className='rounded-md border border-red-200 bg-red-50 p-3'>
<p className='text-red-800 text-sm'>{error}</p>
</div>
</div>
)
}
if (!workspaceId) {
return null
}
return (
<div className='mt-4 transition-all duration-300'>
<h3 className='mb-3 font-medium text-sm'>Sent Invitations</h3>
{isLoading ? (
<TableSkeleton />
) : invitations.length === 0 ? (
<div className='rounded-md border bg-muted/30 p-4 text-center'>
<p className='text-muted-foreground text-sm'>No pending invitations</p>
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[250px] overflow-auto'>
<Table>
<TableHeader>
<TableRow className='border-x-0 border-t-0 hover:bg-transparent'>
<TableHead className='px-3 py-2 font-medium text-muted-foreground text-xs'>
Email
</TableHead>
<TableHead className='px-3 py-2 font-medium text-muted-foreground text-xs'>
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((invitation) => (
<TableRow
key={invitation.id}
className='border-x-0 border-t-0 hover:bg-accent/50'
>
<TableCell className='px-3 py-2'>
<span
className='block max-w-[200px] truncate font-medium text-sm'
title={invitation.email}
>
{invitation.email}
</span>
</TableCell>
<TableCell className='px-3 py-2'>
<span className={getInvitationStatusStyles(invitation.status)}>
{formatInvitationStatus(invitation.status)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LogOut, Plus, Send, Trash2 } from 'lucide-react'
import { LogOut, Pencil, Plus, Send, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -14,12 +14,13 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
const logger = createLogger('WorkspaceSelector')
@@ -45,6 +46,7 @@ interface WorkspaceSelectorProps {
onCreateWorkspace: () => Promise<void>
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
onLeaveWorkspace: (workspace: Workspace) => Promise<void>
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
isDeleting: boolean
isLeaving: boolean
isCreating: boolean
@@ -59,6 +61,7 @@ export function WorkspaceSelector({
onCreateWorkspace,
onDeleteWorkspace,
onLeaveWorkspace,
updateWorkspaceName,
isDeleting,
isLeaving,
isCreating,
@@ -68,9 +71,23 @@ export function WorkspaceSelector({
// State
const [showInviteMembers, setShowInviteMembers] = useState(false)
const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState<string | null>(null)
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isRenaming, setIsRenaming] = useState(false)
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
const [leaveConfirmationName, setLeaveConfirmationName] = useState('')
// Refs
const scrollAreaRef = useRef<HTMLDivElement>(null)
const editInputRef = useRef<HTMLInputElement>(null)
// Focus input when editing starts
useEffect(() => {
if (editingWorkspaceId && editInputRef.current) {
editInputRef.current.focus()
editInputRef.current.select()
}
}, [editingWorkspaceId])
/**
* Scroll to active workspace on load or when it changes
@@ -91,6 +108,103 @@ export function WorkspaceSelector({
}
}, [activeWorkspace, isWorkspacesLoading])
/**
* Handle start editing workspace name
*/
const handleStartEdit = useCallback((workspace: Workspace, e: React.MouseEvent) => {
// Only allow admins to rename workspace
if (workspace.permissions !== 'admin') {
return
}
e.stopPropagation()
setEditingWorkspaceId(workspace.id)
setEditingName(workspace.name)
}, [])
/**
* Handle save edit
*/
const handleSaveEdit = useCallback(async () => {
if (!editingWorkspaceId || !editingName.trim()) {
setEditingWorkspaceId(null)
setEditingName('')
return
}
const workspace = workspaces.find((w) => w.id === editingWorkspaceId)
if (!workspace || editingName.trim() === workspace.name) {
setEditingWorkspaceId(null)
setEditingName('')
return
}
setIsRenaming(true)
try {
await updateWorkspaceName(editingWorkspaceId, editingName.trim())
logger.info(
`Successfully renamed workspace from "${workspace.name}" to "${editingName.trim()}"`
)
setEditingWorkspaceId(null)
setEditingName('')
} catch (error) {
logger.error('Failed to rename workspace:', {
error,
workspaceId: editingWorkspaceId,
oldName: workspace.name,
newName: editingName.trim(),
})
// Reset to original name on error
setEditingName(workspace.name)
} finally {
setIsRenaming(false)
}
}, [editingWorkspaceId, editingName, workspaces, updateWorkspaceName])
/**
* Handle cancel edit
*/
const handleCancelEdit = useCallback(() => {
setEditingWorkspaceId(null)
setEditingName('')
}, [])
/**
* Handle keyboard events
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
},
[handleSaveEdit, handleCancelEdit]
)
/**
* Handle input blur
*/
const handleInputBlur = useCallback(() => {
handleSaveEdit()
}, [handleSaveEdit])
/**
* Handle workspace click
*/
const handleWorkspaceClick = useCallback(
(workspace: Workspace, e: React.MouseEvent) => {
if (editingWorkspaceId) {
e.preventDefault()
return
}
onSwitchWorkspace(workspace)
},
[editingWorkspaceId, onSwitchWorkspace]
)
/**
* Confirm delete workspace
*/
@@ -117,8 +231,14 @@ export function WorkspaceSelector({
return (
<div className='space-y-1'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex w-full items-center justify-between rounded-lg p-2'>
<Skeleton className='h-[20px] w-32' />
<div
key={i}
className='flex h-8 items-center rounded-[8px] p-2 text-left'
style={{ maxWidth: '206px' }}
>
<div className='flex min-w-0 flex-1 items-center text-left'>
<Skeleton className='h-4 w-32' />
</div>
</div>
))}
</div>
@@ -127,125 +247,216 @@ export function WorkspaceSelector({
return (
<div className='space-y-1'>
{workspaces.map((workspace) => (
<div
key={workspace.id}
data-workspace-id={workspace.id}
onMouseEnter={() => setHoveredWorkspaceId(workspace.id)}
onMouseLeave={() => setHoveredWorkspaceId(null)}
onClick={() => onSwitchWorkspace(workspace)}
className={cn(
'group flex h-9 w-full cursor-pointer items-center rounded-lg p-2 text-left transition-colors',
activeWorkspace?.id === workspace.id ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
<div className='flex h-full min-w-0 flex-1 items-center text-left'>
<span
className={cn(
'flex-1 truncate font-medium text-sm',
activeWorkspace?.id === workspace.id ? 'text-foreground' : 'text-muted-foreground'
)}
style={{ maxWidth: '168px' }}
>
{workspace.name}
</span>
</div>
{workspaces.map((workspace) => {
const isEditing = editingWorkspaceId === workspace.id
const isHovered = hoveredWorkspaceId === workspace.id
return (
<div
className='flex h-full items-center justify-center'
onClick={(e) => e.stopPropagation()}
>
{hoveredWorkspaceId === workspace.id && (
<>
{/* Leave Workspace - for non-admin users */}
{workspace.permissions !== 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
}}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
>
<LogOut className='h-2 w-2' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave Workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to leave "{workspace.name}"? You will lose access
to all workflows and data in this workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmLeaveWorkspace(workspace)}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isLeaving}
>
{isLeaving ? 'Leaving...' : 'Leave'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Delete Workspace - for admin users */}
{workspace.permissions === 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
}}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
>
<Trash2 className='h-2 w-2' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{workspace.name}"? This action cannot
be undone and will permanently delete all workflows and data in this
workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmDeleteWorkspace(workspace)}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
key={workspace.id}
data-workspace-id={workspace.id}
onMouseEnter={() => setHoveredWorkspaceId(workspace.id)}
onMouseLeave={() => setHoveredWorkspaceId(null)}
onClick={(e) => handleWorkspaceClick(workspace, e)}
className={cn(
'group flex h-8 cursor-pointer items-center rounded-[8px] p-2 text-left transition-colors',
activeWorkspace?.id === workspace.id ? 'bg-muted' : 'hover:bg-muted'
)}
style={{ maxWidth: '206px' }}
>
<div className='flex min-w-0 flex-1 items-center text-left'>
{isEditing ? (
<input
ref={editInputRef}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={cn(
'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
activeWorkspace?.id === workspace.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => e.stopPropagation()}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<span
className={cn(
'min-w-0 flex-1 select-none truncate pr-1 font-medium text-sm',
activeWorkspace?.id === workspace.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
>
{workspace.name}
</span>
)}
</div>
<div
className='flex h-full flex-shrink-0 items-center justify-center gap-1'
onClick={(e) => e.stopPropagation()}
>
{/* Edit button - show on hover for admin users */}
{!isEditing && isHovered && workspace.permissions === 'admin' && (
<Button
variant='ghost'
size='icon'
onClick={(e) => handleStartEdit(workspace, e)}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
<Pencil className='!h-3.5 !w-3.5' />
</Button>
)}
{/* Leave Workspace - for non-admin users */}
{workspace.permissions !== 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => e.stopPropagation()}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<LogOut className='!h-3.5 !w-3.5' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave workspace?</AlertDialogTitle>
<AlertDialogDescription>
Leaving this workspace will remove your access to all associated
workflows, logs, and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name <strong>{workspace.name}</strong> to confirm.
</p>
<Input
value={leaveConfirmationName}
onChange={(e) => setLeaveConfirmationName(e.target.value)}
placeholder='Placeholder'
className='h-9'
/>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setLeaveConfirmationName('')}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmLeaveWorkspace(workspace)
setLeaveConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isLeaving || leaveConfirmationName !== workspace.name}
>
{isLeaving ? 'Leaving...' : 'Leave'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Delete Workspace - for admin users */}
{workspace.permissions === 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => e.stopPropagation()}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<Trash2 className='!h-3.5 !w-3.5' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this workspace will permanently remove all associated workflows,
logs, and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name{' '}
<span className='font-semibold'>{workspace.name}</span> to confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Placeholder'
className='h-9 rounded-[8px]'
/>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setDeleteConfirmationName('')}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmDeleteWorkspace(workspace)
setDeleteConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting || deleteConfirmationName !== workspace.name}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
</div>
))}
)
})}
</div>
)
}
return (
<>
<div className='rounded-[14px] border bg-card shadow-xs'>
<div className='rounded-[10px] border bg-background shadow-xs'>
<div className='flex h-full flex-col p-2'>
{/* Workspace List */}
<div className='min-h-0 flex-1'>
<ScrollArea ref={scrollAreaRef} className='h-[116px]' hideScrollbar={true}>
<ScrollArea ref={scrollAreaRef} className='h-[104px]' hideScrollbar={true}>
{renderWorkspaceList()}
</ScrollArea>
</div>
@@ -253,14 +464,13 @@ export function WorkspaceSelector({
{/* Bottom Actions */}
<div className='mt-2 flex items-center gap-2 border-t pt-2'>
{/* Send Invite - Hide in development */}
{!isDev && (
{isDev && (
<Button
variant='secondary'
size='sm'
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}
disabled={!userPermissions.canAdmin}
className={cn(
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/10 hover:text-muted-foreground',
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground',
!userPermissions.canAdmin && 'cursor-not-allowed opacity-50'
)}
>
@@ -272,11 +482,10 @@ export function WorkspaceSelector({
{/* Create Workspace */}
<Button
variant='secondary'
size='sm'
onClick={onCreateWorkspace}
disabled={isCreating}
className={cn(
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/10 hover:text-muted-foreground',
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground',
isCreating && 'cursor-not-allowed'
)}
>
@@ -288,7 +497,11 @@ export function WorkspaceSelector({
</div>
{/* Invite Modal */}
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
<InviteModal
open={showInviteMembers}
onOpenChange={setShowInviteMembers}
workspaceName={activeWorkspace?.name}
/>
</>
)
}

View File

@@ -8,7 +8,7 @@ import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
import {
CreateMenu,
@@ -32,6 +32,109 @@ const logger = createLogger('Sidebar')
const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
/**
* Optimized auto-scroll hook for smooth drag operations
* Extracted outside component for better performance
*/
const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}
// Heights for dynamic calculation (in px)
const SIDEBAR_HEIGHTS = {
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
@@ -39,7 +142,7 @@ const SIDEBAR_HEIGHTS = {
SEARCH: 48, // h-12
WORKFLOW_SELECTOR: 212, // h-[212px]
NAVIGATION: 48, // h-12 buttons
WORKSPACE_SELECTOR: 183, // accurate height: p-2(16) + h-[116px](116) + mt-2(8) + border-t(1) + pt-2(8) + h-8(32) = 181px
WORKSPACE_SELECTOR: 171, // optimized height: p-2(16) + h-[104px](104) + mt-2(8) + border-t(1) + pt-2(8) + h-8(32) = 169px
}
/**
@@ -123,6 +226,9 @@ export function Sidebar() {
const [isDeleting, setIsDeleting] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
// Auto-scroll state for drag operations
const [isDragging, setIsDragging] = useState(false)
// Update activeWorkspace ref when state changes
activeWorkspaceRef.current = activeWorkspace
@@ -140,6 +246,31 @@ export function Sidebar() {
return logsPageRegex.test(pathname)
}, [pathname])
// Use optimized auto-scroll hook
const { handleDragOver, stopScroll } = useAutoScroll(workflowScrollAreaRef)
// Consolidated drag event management with optimized cleanup
useEffect(() => {
if (!isDragging) return
const handleDragEnd = () => {
setIsDragging(false)
stopScroll()
}
const options = { passive: true } as const
document.addEventListener('dragover', handleDragOver, options)
document.addEventListener('dragend', handleDragEnd, options)
document.addEventListener('drop', handleDragEnd, options)
return () => {
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('dragend', handleDragEnd)
document.removeEventListener('drop', handleDragEnd)
stopScroll()
}
}, [isDragging, handleDragOver, stopScroll])
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
@@ -722,6 +853,30 @@ export function Sidebar() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// Optimized drag detection with memoized selectors
useEffect(() => {
const handleDragStart = (e: DragEvent) => {
const target = e.target as HTMLElement
const sidebarElement = workflowScrollAreaRef.current
// Early exit if not in sidebar
if (!sidebarElement?.contains(target)) return
// Efficient draggable check - check element first, then traverse up
if (target.draggable || target.closest('[data-workflow-id], [draggable="true"]')) {
setIsDragging(true)
}
}
const options = { capture: true, passive: true } as const
document.addEventListener('dragstart', handleDragStart, options)
return () => {
document.removeEventListener('dragstart', handleDragStart, options)
stopScroll() // Cleanup on unmount
}
}, [stopScroll])
// Navigation items with their respective actions
const navigationItems = [
{
@@ -777,14 +932,13 @@ export function Sidebar() {
onToggleSidebar={toggleSidebarCollapsed}
activeWorkspace={activeWorkspace}
isWorkspacesLoading={isWorkspacesLoading}
updateWorkspaceName={updateWorkspaceName}
/>
</div>
{/* 2. Workspace Selector - Conditionally rendered */}
{/* 2. Workspace Selector */}
<div
className={`pointer-events-auto flex-shrink-0 ${
!isWorkspaceSelectorVisible || isSidebarCollapsed ? 'hidden' : ''
!isWorkspaceSelectorVisible ? 'hidden' : ''
}`}
>
<WorkspaceSelector
@@ -796,6 +950,7 @@ export function Sidebar() {
onCreateWorkspace={handleCreateWorkspace}
onDeleteWorkspace={confirmDeleteWorkspace}
onLeaveWorkspace={handleLeaveWorkspace}
updateWorkspaceName={updateWorkspaceName}
isDeleting={isDeleting}
isLeaving={isLeaving}
isCreating={isCreatingWorkspace}
@@ -808,24 +963,19 @@ export function Sidebar() {
>
<button
onClick={() => setShowSearchModal(true)}
className='flex h-12 w-full cursor-pointer items-center gap-2 rounded-[14px] border bg-card pr-[10px] pl-3 shadow-xs transition-colors hover:bg-muted/50'
className='flex h-12 w-full cursor-pointer items-center gap-2 rounded-[10px] border bg-background pr-[10px] pl-3 shadow-xs transition-colors hover:bg-muted/50'
>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<span className='flex h-8 flex-1 items-center px-0 text-muted-foreground text-sm leading-none'>
Search anything
</span>
<kbd className='flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
<span className='text-lg'></span>
<span className='text-xs'>K</span>
</span>
</kbd>
<KeyboardShortcut shortcut={getKeyboardShortcutText('K', true)} />
</button>
</div>
{/* 4. Workflow Selector */}
<div
className={`pointer-events-auto relative h-[212px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs ${
className={`pointer-events-auto relative h-[212px] flex-shrink-0 rounded-[10px] border bg-background shadow-xs ${
isSidebarCollapsed ? 'hidden' : ''
}`}
>
@@ -834,7 +984,6 @@ export function Sidebar() {
<FolderTree
regularWorkflows={regularWorkflows}
marketplaceWorkflows={tempWorkflows}
isCollapsed={false}
isLoading={isLoading}
onCreateWorkflow={handleCreateWorkflow}
/>
@@ -844,7 +993,6 @@ export function Sidebar() {
<div className='absolute top-2 right-2'>
<CreateMenu
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={false}
isCreatingWorkflow={isCreatingWorkflow}
/>
</div>
@@ -855,7 +1003,7 @@ export function Sidebar() {
{/* Floating Toolbar - Only on workflow pages */}
<div
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs ${
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[10px] border bg-background shadow-xs ${
!isOnWorkflowPage || isSidebarCollapsed ? 'hidden' : ''
}`}
style={{
@@ -871,7 +1019,7 @@ export function Sidebar() {
{/* Floating Logs Filters - Only on logs page */}
<div
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs ${
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[10px] border bg-background shadow-xs ${
!isOnLogsPage || isSidebarCollapsed ? 'hidden' : ''
}`}
style={{
@@ -911,6 +1059,38 @@ export function Sidebar() {
)
}
// Keyboard Shortcut Component
interface KeyboardShortcutProps {
shortcut: string
className?: string
}
const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
const parts = shortcut.split('+')
// Helper function to determine if a part is a symbol that should be larger
const isSymbol = (part: string) => {
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
}
return (
<kbd
className={cn(
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
className
)}
>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
{parts.map((part, index) => (
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
{part}
</span>
))}
</span>
</kbd>
)
}
// Navigation Item Component
interface NavigationItemProps {
item: {
@@ -938,7 +1118,7 @@ const NavigationItem = ({ item }: NavigationItemProps) => {
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200',
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover && 'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
item.active && 'border-[#701FFC] bg-[#701FFC] text-white'
@@ -956,9 +1136,8 @@ const NavigationItem = ({ item }: NavigationItemProps) => {
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
<span>{item.tooltip}</span>
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)
@@ -967,9 +1146,8 @@ const NavigationItem = ({ item }: NavigationItemProps) => {
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
<span>{item.tooltip}</span>
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
<TooltipContent side='top' command={item.shortcut}>
{item.tooltip}
</TooltipContent>
</Tooltip>
)

View File

@@ -2,6 +2,7 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { X } from 'lucide-react'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -11,37 +12,117 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
// Context for communication between overlay and content
const AlertDialogCloseContext = React.createContext<{
triggerClose: () => void
} | null>(null)
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
ref={ref}
/>
))
>(({ className, style, onClick, ...props }, ref) => {
const [isStable, setIsStable] = React.useState(false)
const closeContext = React.useContext(AlertDialogCloseContext)
React.useEffect(() => {
// Add a small delay before allowing overlay interactions to prevent rapid state changes
const timer = setTimeout(() => setIsStable(true), 150)
return () => clearTimeout(timer)
}, [])
return (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
className
)}
style={{ backdropFilter: 'blur(1.5px)', ...style }}
onClick={(e) => {
// Only allow overlay clicks after component is stable
if (!isStable) {
e.preventDefault()
return
}
// Only close if clicking directly on the overlay, not child elements
if (e.target === e.currentTarget) {
// Trigger close via context
closeContext?.triggerClose()
}
// Call original onClick if provided
onClick?.(e)
}}
{...props}
ref={ref}
/>
)
})
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
))
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & {
hideCloseButton?: boolean
}
>(({ className, children, hideCloseButton = false, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
const hiddenCancelRef = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
// Prevent rapid interactions that can cause instability
const timer = setTimeout(() => setIsInteractionReady(true), 100)
return () => clearTimeout(timer)
}, [])
const closeContextValue = React.useMemo(
() => ({
triggerClose: () => hiddenCancelRef.current?.click(),
}),
[]
)
return (
<AlertDialogPortal>
<AlertDialogCloseContext.Provider value={closeContextValue}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-border bg-background px-6 py-5 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
onPointerDown={(e) => {
// Prevent event bubbling that might interfere with parent hover states
e.stopPropagation()
}}
onPointerUp={(e) => {
// Prevent event bubbling that might interfere with parent hover states
e.stopPropagation()
}}
{...props}
>
{children}
{!hideCloseButton && (
<AlertDialogPrimitive.Cancel
className='absolute top-4 right-4 h-4 w-4 border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:bg-transparent hover:bg-transparent hover:text-foreground focus:outline-none disabled:pointer-events-none'
disabled={!isInteractionReady}
tabIndex={-1}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</AlertDialogPrimitive.Cancel>
)}
{/* Hidden cancel button for overlay clicks */}
<AlertDialogPrimitive.Cancel
ref={hiddenCancelRef}
style={{ display: 'none' }}
tabIndex={-1}
aria-hidden='true'
/>
</AlertDialogPrimitive.Content>
</AlertDialogCloseContext.Provider>
</AlertDialogPortal>
)
})
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@@ -75,7 +156,7 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
className={cn('font-[360] text-sm', className)}
{...props}
/>
))

View File

@@ -16,16 +16,38 @@ const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
))
>(({ className, style, ...props }, ref) => {
const [isStable, setIsStable] = React.useState(false)
React.useEffect(() => {
// Add a small delay before allowing overlay interactions to prevent rapid state changes
const timer = setTimeout(() => setIsStable(true), 150)
return () => clearTimeout(timer)
}, [])
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
className
)}
style={{ backdropFilter: 'blur(1.5px)', ...style }}
onPointerDown={(e) => {
// Only allow overlay clicks after component is stable
if (!isStable) {
e.preventDefault()
return
}
// Ensure click is on the overlay itself, not a child
if (e.target !== e.currentTarget) {
e.preventDefault()
}
}}
{...props}
/>
)
})
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
@@ -33,27 +55,70 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideCloseButton?: boolean
}
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg',
className
)}
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className='absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, hideCloseButton = false, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
React.useEffect(() => {
// Prevent rapid interactions that can cause instability
const timer = setTimeout(() => setIsInteractionReady(true), 100)
return () => clearTimeout(timer)
}, [])
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
onInteractOutside={(e) => {
// Prevent accidental closes from child element interactions during initial render
if (!isInteractionReady) {
e.preventDefault()
return
}
// More restrictive interaction outside handling
const target = e.target as Element
if (target.closest('[role="dialog"]')) {
e.preventDefault()
}
}}
onEscapeKeyDown={(e) => {
// Prevent escape during rapid interactions
if (!isInteractionReady) {
e.preventDefault()
return
}
// Allow escape but prevent event bubbling issues
e.stopPropagation()
}}
onPointerDown={(e) => {
// Prevent event bubbling that might interfere with parent hover states
e.stopPropagation()
}}
onPointerUp={(e) => {
// Prevent event bubbling that might interfere with parent hover states
e.stopPropagation()
}}
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close
className='absolute top-4 right-4 h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus:outline-none disabled:pointer-events-none'
disabled={!isInteractionReady}
tabIndex={-1}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@@ -75,7 +140,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('font-semibold text-lg leading-none tracking-tight', className)}
className={cn('font-medium text-lg leading-none tracking-tight', className)}
{...props}
/>
))

View File

@@ -17,12 +17,13 @@ const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
>(({ className, style, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
className
)}
style={{ backdropFilter: 'blur(4.8px)', ...style }}
{...props}
ref={ref}
/>

View File

@@ -68,7 +68,6 @@
"@react-email/components": "^0.0.34",
"@sentry/nextjs": "^9.15.0",
"@trigger.dev/sdk": "3.3.17",
"@types/react-syntax-highlighter": "15.5.13",
"@types/three": "0.177.0",
"@vercel/og": "^0.6.5",
"@vercel/speed-insights": "^1.2.0",
@@ -112,7 +111,6 @@
"react-hook-form": "^7.54.2",
"react-markdown": "^10.1.0",
"react-simple-code-editor": "^0.14.1",
"react-syntax-highlighter": "15.6.1",
"reactflow": "^11.11.4",
"recharts": "2.15.3",
"rehype-highlight": "7.0.2",

View File

@@ -53,6 +53,10 @@ export default {
'5': 'hsl(var(--chart-5))',
},
},
fontWeight: {
medium: '460',
semibold: '540',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',

684
bun.lock

File diff suppressed because it is too large Load Diff