mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
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:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
@@ -1,2 +0,0 @@
|
||||
export { CreateMenu } from './create-menu'
|
||||
export { ImportControls, type ImportControlsRef } from './import-controls'
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user