Compare commits

..

6 Commits

Author SHA1 Message Date
waleed
9994ae4b02 upgraded turborepo 2026-01-11 21:44:52 -08:00
waleed
29031f6d3b standardized import/export hooks 2026-01-11 21:41:00 -08:00
waleed
56616efad2 ack pr comments 2026-01-11 21:19:37 -08:00
waleed
e34a28c331 fixed flicker on importing multiple workflows 2026-01-11 21:15:56 -08:00
Emir Karabeg
f0f9c91ab0 improvement(import): loading animation 2026-01-11 21:06:12 -08:00
waleed
d0c0983e93 feat(export): added the ability to export workflow 2026-01-11 20:57:19 -08:00
23 changed files with 451 additions and 280 deletions

View File

@@ -108,7 +108,7 @@ export function Panel() {
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
getWorkflowIds: () => activeWorkflowId || '',
workflowIds: activeWorkflowId || '',
isActive: true,
onSuccess: () => setIsDeleteModalOpen(false),
})

View File

@@ -373,7 +373,7 @@ export function ContextMenu({
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
/>
<button
type='button'

View File

@@ -20,6 +20,7 @@ import {
useCanDelete,
useDeleteFolder,
useDuplicateFolder,
useExportFolder,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
@@ -57,23 +58,24 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const { canDeleteFolder } = useCanDelete({ workspaceId })
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
// Delete folder hook
const { isDeleting, handleDeleteFolder } = useDeleteFolder({
workspaceId,
getFolderIds: () => folder.id,
folderIds: folder.id,
onSuccess: () => setIsDeleteModalOpen(false),
})
// Duplicate folder hook
const { handleDuplicateFolder } = useDuplicateFolder({
workspaceId,
getFolderIds: () => folder.id,
folderIds: folder.id,
})
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
workspaceId,
folderId: folder.id,
})
// Folder expand hook - must be declared before callbacks that use expandFolder
const {
isExpanded,
handleToggleExpanded,
@@ -90,7 +92,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
*/
const handleCreateWorkflowInFolder = useCallback(async () => {
try {
// Generate name and color upfront for optimistic updates
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
@@ -103,15 +104,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
if (result.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
// Expand the parent folder so the new workflow is visible
expandFolder()
// Scroll to the newly created workflow
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
}
} catch (error) {
// Error already handled by mutation's onError callback
logger.error('Failed to create workflow in folder:', error)
}
}, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder])
@@ -128,9 +126,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
parentId: folder.id,
})
if (result.id) {
// Expand the parent folder so the new folder is visible
expandFolder()
// Scroll to the newly created folder
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
@@ -147,7 +143,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
// Don't start drag if editing
if (isEditing) {
e.preventDefault()
return
@@ -159,12 +154,10 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
[folder.id]
)
// Item drag hook
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
onDragStart,
})
// Context menu hook
const {
isOpen: isContextMenuOpen,
position,
@@ -174,7 +167,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
preventDismiss,
} = useContextMenu()
// Rename hook
const {
isEditing,
editValue,
@@ -258,7 +250,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
@@ -365,13 +356,16 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
onCreate={handleCreateWorkflowInFolder}
onCreateFolder={handleCreateFolderInFolder}
onDuplicate={handleDuplicateFolder}
onExport={handleExportFolder}
onDelete={() => setIsDeleteModalOpen(true)}
showCreate={true}
showCreateFolder={true}
showExport={true}
disableRename={!userPermissions.canEdit}
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableDuplicate={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit || !hasWorkflows}
disableExport={!userPermissions.canEdit || isExporting || !hasWorkflows}
disableDelete={!userPermissions.canEdit || !canDelete}
/>

View File

@@ -79,27 +79,21 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
getWorkflowIds: () => workflowIdsToDelete,
workflowIds: workflowIdsToDelete,
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
onSuccess: () => setIsDeleteModalOpen(false),
})
// Duplicate workflow hook
// Duplicate workflow hook (uses captured selection from right-click)
const { handleDuplicateWorkflow } = useDuplicateWorkflow({
workspaceId,
getWorkflowIds: () => {
// Use the selection captured at right-click time
return capturedSelectionRef.current?.workflowIds || []
},
workflowIds: capturedSelectionRef.current?.workflowIds || [],
})
// Export workflow hook
// Export workflow hook (uses captured selection from right-click)
const { handleExportWorkflow } = useExportWorkflow({
workspaceId,
getWorkflowIds: () => {
// Use the selection captured at right-click time
return capturedSelectionRef.current?.workflowIds || []
},
workflowIds: capturedSelectionRef.current?.workflowIds || [],
})
/**

View File

@@ -9,7 +9,6 @@ import {
useDragDrop,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -25,15 +24,13 @@ const TREE_SPACING = {
interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[]
isLoading?: boolean
isImporting: boolean
setIsImporting: (value: boolean) => void
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
fileInputRef: React.RefObject<HTMLInputElement | null>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
* Uses the workflow import hook for handling JSON imports.
*
* @param props - Component props
* @returns Workflow list with folders and drag-drop support
@@ -41,8 +38,7 @@ interface WorkflowListProps {
export function WorkflowList({
regularWorkflows,
isLoading = false,
isImporting,
setIsImporting,
handleFileChange,
fileInputRef,
scrollContainerRef,
}: WorkflowListProps) {
@@ -65,9 +61,6 @@ export function WorkflowList({
createFolderHeaderHoverHandlers,
} = useDragDrop()
// Workflow import hook
const { handleFileChange } = useImportWorkflow({ workspaceId })
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {

View File

@@ -2,10 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowDown, Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, FolderPlus, Library, Tooltip } from '@/components/emcn'
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -30,6 +30,7 @@ import {
import {
useDuplicateWorkspace,
useExportWorkspace,
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -85,9 +86,11 @@ export function Sidebar() {
const isCollapsed = hasHydrated ? isCollapsedStore : false
const isOnWorkflowPage = !!workflowId
const [isImporting, setIsImporting] = useState(false)
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
workspaceId,
})
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
useImportWorkspace()
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
@@ -213,7 +216,7 @@ export function Sidebar() {
}, [activeNavItemHref])
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
getWorkspaceId: () => workspaceId,
workspaceId,
})
const searchModalWorkflows = useMemo(
@@ -565,21 +568,31 @@ export function Sidebar() {
Workflows
</div>
<div className='flex items-center justify-center gap-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={isImporting || !canEdit}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
{isImporting ? (
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
disabled={!canEdit || isImporting}
>
<Loader className='h-[14px] w-[14px]' animate />
</Button>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={!canEdit}
>
<Download className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Import workflows</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -622,8 +635,7 @@ export function Sidebar() {
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
isImporting={isImporting}
setIsImporting={setIsImporting}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
/>

View File

@@ -4,6 +4,7 @@ export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'
export { useDuplicateWorkflow } from './use-duplicate-workflow'
export { useDuplicateWorkspace } from './use-duplicate-workspace'
export { useExportFolder } from './use-export-folder'
export { useExportWorkflow } from './use-export-workflow'
export { useExportWorkspace } from './use-export-workspace'
export { useImportWorkflow } from './use-import-workflow'

View File

@@ -11,10 +11,9 @@ interface UseDeleteFolderProps {
*/
workspaceId: string
/**
* Function that returns the folder ID(s) to delete
* This function is called when deletion occurs to get fresh selection state
* The folder ID(s) to delete
*/
getFolderIds: () => string | string[]
folderIds: string | string[]
/**
* Optional callback after successful deletion
*/
@@ -24,17 +23,10 @@ interface UseDeleteFolderProps {
/**
* Hook for managing folder deletion.
*
* Handles:
* - Single or bulk folder deletion
* - Calling delete API for each folder
* - Loading state management
* - Error handling and logging
* - Clearing selection after deletion
*
* @param props - Hook configuration
* @returns Delete folder handlers and state
*/
export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDeleteFolderProps) {
export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDeleteFolderProps) {
const deleteFolderMutation = useDeleteFolderMutation()
const [isDeleting, setIsDeleting] = useState(false)
@@ -46,23 +38,18 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel
return
}
if (!folderIds) {
return
}
setIsDeleting(true)
try {
// Get fresh folder IDs at deletion time
const folderIdsOrId = getFolderIds()
if (!folderIdsOrId) {
return
}
const folderIdsToDelete = Array.isArray(folderIds) ? folderIds : [folderIds]
// Normalize to array for consistent handling
const folderIdsToDelete = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
// Delete each folder sequentially
for (const folderId of folderIdsToDelete) {
await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId })
}
// Clear selection after successful deletion
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -74,7 +61,7 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel
} finally {
setIsDeleting(false)
}
}, [getFolderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
}, [folderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
return {
isDeleting,

View File

@@ -12,10 +12,9 @@ interface UseDeleteWorkflowProps {
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to delete
* This function is called when deletion occurs to get fresh selection state
* Workflow ID(s) to delete
*/
getWorkflowIds: () => string | string[]
workflowIds: string | string[]
/**
* Whether the active workflow is being deleted
* Can be a boolean or a function that receives the workflow IDs
@@ -30,20 +29,12 @@ interface UseDeleteWorkflowProps {
/**
* Hook for managing workflow deletion with navigation logic.
*
* Handles:
* - Single or bulk workflow deletion
* - Finding next workflow to navigate to
* - Navigating before deletion (if active workflow)
* - Removing workflow(s) from registry
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Delete workflow handlers and state
*/
export function useDeleteWorkflow({
workspaceId,
getWorkflowIds,
workflowIds,
isActive = false,
onSuccess,
}: UseDeleteWorkflowProps) {
@@ -59,30 +50,21 @@ export function useDeleteWorkflow({
return
}
if (!workflowIds) {
return
}
setIsDeleting(true)
try {
// Get fresh workflow IDs at deletion time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
const workflowIdsToDelete = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
// Normalize to array for consistent handling
const workflowIdsToDelete = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
// Determine if active workflow is being deleted
const isActiveWorkflowBeingDeleted =
typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive
// Find next workflow to navigate to (if active workflow is being deleted)
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
// Find which specific workflow is the active one (if any in the deletion list)
let activeWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') {
// Check each workflow being deleted to find which one is active
activeWorkflowId =
workflowIdsToDelete.find((id) => isActive([id])) || workflowIdsToDelete[0]
} else {
@@ -93,13 +75,11 @@ export function useDeleteWorkflow({
let nextWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && sidebarWorkflows.length > workflowIdsToDelete.length) {
// Find the first workflow that's not being deleted
const remainingWorkflows = sidebarWorkflows.filter(
(w) => !workflowIdsToDelete.includes(w.id)
)
if (remainingWorkflows.length > 0) {
// Try to find the next workflow after the current one
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
return idx > currentIndex
@@ -108,13 +88,11 @@ export function useDeleteWorkflow({
if (workflowsAfterCurrent.length > 0) {
nextWorkflowId = workflowsAfterCurrent[0].id
} else {
// Otherwise, use the first remaining workflow
nextWorkflowId = remainingWorkflows[0].id
}
}
}
// Navigate first if this is the active workflow
if (isActiveWorkflowBeingDeleted) {
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
@@ -123,10 +101,8 @@ export function useDeleteWorkflow({
}
}
// Delete all workflows
await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id)))
// Clear selection after successful deletion
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -138,16 +114,7 @@ export function useDeleteWorkflow({
} finally {
setIsDeleting(false)
}
}, [
getWorkflowIds,
isDeleting,
workflows,
workspaceId,
isActive,
router,
removeWorkflow,
onSuccess,
])
}, [workflowIds, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess])
return {
isDeleting,

View File

@@ -7,7 +7,10 @@ const logger = createLogger('useDuplicateFolder')
interface UseDuplicateFolderProps {
workspaceId: string
getFolderIds: () => string | string[]
/**
* The folder ID(s) to duplicate
*/
folderIds: string | string[]
onSuccess?: () => void
}
@@ -17,11 +20,7 @@ interface UseDuplicateFolderProps {
* @param props - Hook configuration
* @returns Duplicate folder handlers and state
*/
export function useDuplicateFolder({
workspaceId,
getFolderIds,
onSuccess,
}: UseDuplicateFolderProps) {
export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDuplicateFolderProps) {
const duplicateFolderMutation = useDuplicateFolderMutation()
const [isDuplicating, setIsDuplicating] = useState(false)
@@ -46,21 +45,17 @@ export function useDuplicateFolder({
return
}
if (!folderIds) {
return
}
setIsDuplicating(true)
try {
// Get fresh folder IDs at duplication time
const folderIdsOrId = getFolderIds()
if (!folderIdsOrId) {
return
}
// Normalize to array for consistent handling
const folderIdsToDuplicate = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds]
const duplicatedIds: string[] = []
const folderStore = useFolderStore.getState()
// Duplicate each folder sequentially
for (const folderId of folderIdsToDuplicate) {
const folder = folderStore.getFolderById(folderId)
@@ -72,7 +67,6 @@ export function useDuplicateFolder({
const siblingNames = new Set(
folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name)
)
// Avoid colliding with the original folder name
siblingNames.add(folder.name)
const duplicateName = generateDuplicateName(folder.name, siblingNames)
@@ -90,7 +84,6 @@ export function useDuplicateFolder({
}
}
// Clear selection after successful duplication
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -107,7 +100,7 @@ export function useDuplicateFolder({
setIsDuplicating(false)
}
}, [
getFolderIds,
folderIds,
generateDuplicateName,
isDuplicating,
duplicateFolderMutation,

View File

@@ -14,10 +14,9 @@ interface UseDuplicateWorkflowProps {
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to duplicate
* This function is called when duplication occurs to get fresh selection state
* Workflow ID(s) to duplicate
*/
getWorkflowIds: () => string | string[]
workflowIds: string | string[]
/**
* Optional callback after successful duplication
*/
@@ -27,21 +26,12 @@ interface UseDuplicateWorkflowProps {
/**
* Hook for managing workflow duplication with optimistic updates.
*
* Handles:
* - Single or bulk workflow duplication
* - Optimistic UI updates (shows new workflow immediately)
* - Automatic rollback on failure
* - Loading state management
* - Error handling and logging
* - Clearing selection after duplication
* - Navigation to duplicated workflow (single only)
*
* @param props - Hook configuration
* @returns Duplicate workflow handlers and state
*/
export function useDuplicateWorkflow({
workspaceId,
getWorkflowIds,
workflowIds,
onSuccess,
}: UseDuplicateWorkflowProps) {
const router = useRouter()
@@ -52,25 +42,19 @@ export function useDuplicateWorkflow({
* Duplicate the workflow(s)
*/
const handleDuplicateWorkflow = useCallback(async () => {
if (!workflowIds) {
return
}
if (duplicateMutation.isPending) {
return
}
// Get fresh workflow IDs at duplication time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
// Normalize to array for consistent handling
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
const workflowIdsToDuplicate = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
const duplicatedIds: string[] = []
try {
// Duplicate each workflow sequentially
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflows[sourceId]
if (!sourceWorkflow) {
@@ -90,7 +74,6 @@ export function useDuplicateWorkflow({
duplicatedIds.push(result.id)
}
// Clear selection after successful duplication
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -99,7 +82,6 @@ export function useDuplicateWorkflow({
duplicatedIds,
})
// Navigate to duplicated workflow if single duplication
if (duplicatedIds.length === 1) {
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
}
@@ -109,7 +91,7 @@ export function useDuplicateWorkflow({
logger.error('Error duplicating workflow(s):', { error })
throw error
}
}, [getWorkflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess])
}, [workflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess])
return {
isDuplicating: duplicateMutation.isPending,

View File

@@ -6,10 +6,9 @@ const logger = createLogger('useDuplicateWorkspace')
interface UseDuplicateWorkspaceProps {
/**
* Function that returns the workspace ID to duplicate
* This function is called when duplication occurs to get fresh state
* The workspace ID to duplicate
*/
getWorkspaceId: () => string | null
workspaceId: string | null
/**
* Optional callback after successful duplication
*/
@@ -19,17 +18,10 @@ interface UseDuplicateWorkspaceProps {
/**
* Hook for managing workspace duplication.
*
* Handles:
* - Workspace duplication
* - Calling duplicate API
* - Loading state management
* - Error handling and logging
* - Navigation to duplicated workspace
*
* @param props - Hook configuration
* @returns Duplicate workspace handlers and state
*/
export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
const router = useRouter()
const [isDuplicating, setIsDuplicating] = useState(false)
@@ -38,18 +30,12 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
*/
const handleDuplicateWorkspace = useCallback(
async (workspaceName: string) => {
if (isDuplicating) {
if (isDuplicating || !workspaceId) {
return
}
setIsDuplicating(true)
try {
// Get fresh workspace ID at duplication time
const workspaceId = getWorkspaceId()
if (!workspaceId) {
return
}
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -70,7 +56,6 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
workflowsCount: duplicatedWorkspace.workflowsCount,
})
// Navigate to duplicated workspace
router.push(`/workspace/${duplicatedWorkspace.id}/w`)
onSuccess?.()
@@ -83,7 +68,7 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
setIsDuplicating(false)
}
},
[getWorkspaceId, isDuplicating, router, onSuccess]
[workspaceId, isDuplicating, router, onSuccess]
)
return {

View File

@@ -0,0 +1,237 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* The folder ID to export
*/
folderId: string
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Recursively collects all workflow IDs within a folder and its subfolders.
*
* @param folderId - The folder ID to collect workflows from
* @param workflows - All workflows in the workspace
* @param folders - All folders in the workspace
* @returns Array of workflow IDs
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): string[] {
const workflowIds: string[] = []
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
workflowIds.push(workflow.id)
}
}
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
workflowIds.push(...childWorkflowIds)
}
}
return workflowIds
}
/**
* Hook for managing folder export to ZIP.
*
* @param props - Hook configuration
* @returns Export folder handlers and state
*/
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
const [isExporting, setIsExporting] = useState(false)
/**
* Check if the folder has any workflows (recursively)
*/
const hasWorkflows = useMemo(() => {
if (!folderId) return false
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
}, [folderId, workflows, folders])
/**
* Download file helper
*/
const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export all workflows in the folder (including nested subfolders) to ZIP
*/
const handleExportFolder = useCallback(async () => {
if (isExporting) {
return
}
if (!folderId) {
logger.warn('No folder ID provided for export')
return
}
setIsExporting(true)
try {
const folderStore = useFolderStore.getState()
const folder = folderStore.getFolderById(folderId)
if (!folder) {
logger.warn('Folder not found for export', { folderId })
return
}
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
if (workflowIdsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
logger.info('Starting folder export', {
folderId,
folderName: folder.name,
workflowCount: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported from folder', {
folderId,
folderName: folder.name,
})
return
}
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: exportedWorkflows.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting folder:', { error })
throw error
} finally {
setIsExporting(false)
}
}, [folderId, isExporting, workflows, onSuccess])
return {
isExporting,
hasWorkflows,
handleExportFolder,
}
}

View File

@@ -14,10 +14,9 @@ interface UseExportWorkflowProps {
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to export
* This function is called when export occurs to get fresh selection state
* The workflow ID(s) to export
*/
getWorkflowIds: () => string | string[]
workflowIds: string | string[]
/**
* Optional callback after successful export
*/
@@ -27,23 +26,10 @@ interface UseExportWorkflowProps {
/**
* Hook for managing workflow export to JSON.
*
* Handles:
* - Single or bulk workflow export
* - Fetching workflow data and variables from API
* - Sanitizing workflow state for export
* - Downloading as JSON file(s)
* - Loading state management
* - Error handling and logging
* - Clearing selection after export
*
* @param props - Hook configuration
* @returns Export workflow handlers and state
*/
export function useExportWorkflow({
workspaceId,
getWorkflowIds,
onSuccess,
}: UseExportWorkflowProps) {
export function useExportWorkflow({ workspaceId, workflowIds, onSuccess }: UseExportWorkflowProps) {
const { workflows } = useWorkflowRegistry()
const [isExporting, setIsExporting] = useState(false)
@@ -81,18 +67,13 @@ export function useExportWorkflow({
return
}
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
return
}
setIsExporting(true)
try {
// Get fresh workflow IDs at export time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
// Normalize to array for consistent handling
const workflowIdsToExport = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
const workflowIdsToExport = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
logger.info('Starting workflow export', {
workflowIdsToExport,
@@ -101,7 +82,6 @@ export function useExportWorkflow({
const exportedWorkflows: Array<{ name: string; content: string }> = []
// Export each workflow
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
@@ -110,7 +90,6 @@ export function useExportWorkflow({
continue
}
// Fetch workflow state from API
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
@@ -123,7 +102,6 @@ export function useExportWorkflow({
continue
}
// Fetch workflow variables (API returns Record format directly)
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
@@ -131,7 +109,6 @@ export function useExportWorkflow({
workflowVariables = variablesData?.data
}
// Prepare export state
const workflowState = {
...workflowData.state,
metadata: {
@@ -162,17 +139,22 @@ export function useExportWorkflow({
return
}
// Download as single JSON or ZIP depending on count
if (exportedWorkflows.length === 1) {
// Single workflow - download as JSON
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
} else {
// Multiple workflows - download as ZIP
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const filename = `${exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
}
@@ -181,7 +163,6 @@ export function useExportWorkflow({
downloadFile(zipBlob, zipFilename, 'application/zip')
}
// Clear selection after successful export
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -198,7 +179,7 @@ export function useExportWorkflow({
} finally {
setIsExporting(false)
}
}, [getWorkflowIds, isExporting, workflows, onSuccess])
}, [workflowIds, isExporting, workflows, onSuccess])
return {
isExporting,

View File

@@ -44,21 +44,18 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
try {
logger.info('Exporting workspace', { workspaceId })
// Fetch all workflows in workspace
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (!workflowsResponse.ok) {
throw new Error('Failed to fetch workflows')
}
const { data: workflows } = await workflowsResponse.json()
// Fetch all folders in workspace
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
if (!foldersResponse.ok) {
throw new Error('Failed to fetch folders')
}
const foldersData = await foldersResponse.json()
// Export each workflow
const workflowsToExport: WorkflowExportData[] = []
for (const workflow of workflows) {

View File

@@ -33,6 +33,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const createWorkflowMutation = useCreateWorkflow()
const queryClient = useQueryClient()
const createFolderMutation = useCreateFolder()
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
const [isImporting, setIsImporting] = useState(false)
/**
@@ -48,9 +49,8 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
const workflowName = extractWorkflowName(content, filename)
useWorkflowDiffStore.getState().clearDiff()
clearDiff()
// Extract color from metadata
const parsedContent = JSON.parse(content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
@@ -63,7 +63,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
})
const newWorkflowId = result.id
// Update workflow color if we extracted one
if (workflowColor !== '#3972F6') {
await fetch(`/api/workflows/${newWorkflowId}`, {
method: 'PATCH',
@@ -72,16 +71,13 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
})
}
// Save workflow state
await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
})
// Save variables if any (handle both legacy Array and current Record formats)
if (workflowData.variables) {
// Convert to Record format for API (handles backwards compatibility with old Array exports)
const variablesArray = Array.isArray(workflowData.variables)
? workflowData.variables
: Object.values(workflowData.variables)
@@ -114,7 +110,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
logger.info(`Imported workflow: ${workflowName}`)
return newWorkflowId
},
[createWorkflowMutation, workspaceId]
[clearDiff, createWorkflowMutation, workspaceId]
)
/**
@@ -134,7 +130,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const importedWorkflowIds: string[] = []
if (hasZip && fileArray.length === 1) {
// Import from ZIP - preserves folder structure
const zipFile = fileArray[0]
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
@@ -149,7 +144,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
try {
let targetFolderId = importFolder.id
// Recreate nested folder structure
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
@@ -187,7 +181,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
}
} else if (jsonFiles.length > 0) {
// Import multiple JSON files or single JSON
const extractedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
for (const workflow of extractedWorkflows) {
@@ -200,22 +193,21 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
}
// Reload workflows and folders to show newly imported ones
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) })
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
// Navigate to first imported workflow if any
if (importedWorkflowIds.length > 0) {
router.push(`/workspace/${workspaceId}/w/${importedWorkflowIds[0]}`)
router.push(
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
)
}
} catch (error) {
logger.error('Failed to import workflows:', error)
} finally {
setIsImporting(false)
// Reset file input
if (event.target) {
event.target.value = ''
}

View File

@@ -21,15 +21,6 @@ interface UseImportWorkspaceProps {
/**
* Hook for managing workspace import from ZIP files.
*
* Handles:
* - Extracting workflows from ZIP file
* - Creating new workspace
* - Recreating folder structure
* - Importing all workflows with states and variables
* - Navigation to imported workspace
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Import workspace handlers and state
*/
@@ -37,6 +28,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const router = useRouter()
const [isImporting, setIsImporting] = useState(false)
const createFolderMutation = useCreateFolder()
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
/**
* Handle workspace import from ZIP file
@@ -56,7 +48,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
try {
logger.info('Importing workspace from ZIP')
// Extract workflows from ZIP
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
if (extractedWorkflows.length === 0) {
@@ -64,7 +55,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
return
}
// Create new workspace
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const createResponse = await fetch('/api/workspaces', {
method: 'POST',
@@ -81,7 +71,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const folderMap = new Map<string, string>()
// Import workflows
for (const workflow of extractedWorkflows) {
try {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
@@ -91,7 +80,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
continue
}
// Recreate folder structure
let targetFolderId: string | null = null
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
@@ -120,14 +108,12 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
}
const workflowName = extractWorkflowName(workflow.content, workflow.name)
useWorkflowDiffStore.getState().clearDiff()
clearDiff()
// Extract color from workflow metadata
const parsedContent = JSON.parse(workflow.content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
// Create workflow
const createWorkflowResponse = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -147,7 +133,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const newWorkflow = await createWorkflowResponse.json()
// Save workflow state
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -159,9 +144,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
continue
}
// Save variables if any (handle both legacy Array and current Record formats)
if (workflowData.variables) {
// Convert to Record format for API (handles backwards compatibility with old Array exports)
const variablesArray = Array.isArray(workflowData.variables)
? workflowData.variables
: Object.values(workflowData.variables)
@@ -199,7 +182,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
logger.info(`Workspace import complete. Imported ${extractedWorkflows.length} workflows`)
// Navigate to new workspace
router.push(`/workspace/${newWorkspace.id}/w`)
onSuccess?.()
@@ -210,7 +192,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
setIsImporting(false)
}
},
[isImporting, router, onSuccess, createFolderMutation]
[isImporting, router, onSuccess, createFolderMutation, clearDiff]
)
return {

View File

@@ -0,0 +1,22 @@
/**
* Download icon animation
* Subtle continuous animation for import/download states
* Arrow gently pulses down to suggest downloading motion
*/
@keyframes arrow-pulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(1.5px);
opacity: 0.7;
}
}
.animated-download-svg {
animation: arrow-pulse 1.5s ease-in-out infinite;
transform-origin: center center;
}

View File

@@ -0,0 +1,42 @@
import type { SVGProps } from 'react'
import styles from '@/components/emcn/icons/animate/download.module.css'
export interface DownloadProps extends SVGProps<SVGSVGElement> {
/**
* Enable animation on the download icon
* @default false
*/
animate?: boolean
}
/**
* Download icon component with optional CSS-based animation
* Based on lucide arrow-down icon structure.
* When animate is false, this is a lightweight static icon with no animation overhead.
* When animate is true, CSS module animations are applied for a subtle pulsing effect.
* @param props - SVG properties including className, animate, etc.
*/
export function Download({ animate = false, className, ...props }: DownloadProps) {
const svgClassName = animate
? `${styles['animated-download-svg']} ${className || ''}`.trim()
: className
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className={svgClassName}
{...props}
>
<path d='M12 5v14' />
<path d='m19 12-7 7-7-7' />
</svg>
)
}

View File

@@ -5,6 +5,7 @@ export { ChevronDown } from './chevron-down'
export { Connections } from './connections'
export { Copy } from './copy'
export { DocumentAttachment } from './document-attachment'
export { Download } from './download'
export { Duplicate } from './duplicate'
export { Eye } from './eye'
export { FolderCode } from './folder-code'

View File

@@ -97,11 +97,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
return acc
}, {})
set((state) => ({
workflows: mapped,
error: null,
hydration:
state.hydration.phase === 'state-loading'
set((state) => {
// Preserve hydration if workflow is loading or already ready and still exists
const shouldPreserveHydration =
state.hydration.phase === 'state-loading' ||
(state.hydration.phase === 'ready' &&
state.hydration.workflowId &&
mapped[state.hydration.workflowId])
return {
workflows: mapped,
error: null,
hydration: shouldPreserveHydration
? state.hydration
: {
phase: 'metadata-ready',
@@ -110,7 +117,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
requestId: null,
error: null,
},
}))
}
})
},
failMetadataLoad: (workspaceId: string | null, errorMessage: string) => {

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -11,7 +12,7 @@
"drizzle-kit": "^0.31.4",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.3",
"turbo": "2.7.4",
},
},
"apps/docs": {
@@ -3379,19 +3380,19 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.7.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.3", "turbo-darwin-arm64": "2.7.3", "turbo-linux-64": "2.7.3", "turbo-linux-arm64": "2.7.3", "turbo-windows-64": "2.7.3", "turbo-windows-arm64": "2.7.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+HjKlP4OfYk+qzvWNETA3cUO5UuK6b5MSc2UJOKyvBceKucQoQGb2g7HlC2H1GHdkfKrk4YF1VPvROkhVZDDLQ=="],
"turbo": ["turbo@2.7.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.4", "turbo-darwin-arm64": "2.7.4", "turbo-linux-64": "2.7.4", "turbo-linux-arm64": "2.7.4", "turbo-windows-64": "2.7.4", "turbo-windows-arm64": "2.7.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkVrHSq+Bnhl9sX2LQgqQYVfLTWC2gvI74C4758OmU0djfrssDKU9d4YQF0AYXXhIIRZipSXfxClQziIMD+EAg=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
"turbo-linux-64": ["turbo-linux-64@2.7.3", "", { "os": "linux", "cpu": "x64" }, "sha512-GqDsCNnzzr89kMaLGpRALyigUklzgxIrSy2pHZVXyifgczvYPnLglex78Aj3T2gu+T3trPPH2iJ+pWucVOCC2Q=="],
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NdCDTfIcIo3dWjsiaAHlxu5gW61Ed/8maah1IAF/9E3EtX0aAHNiBMbuYLZaR4vRJ7BeVkYB6xKWRtdFLZ0y3g=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
"turbo-windows-64": ["turbo-windows-64@2.7.3", "", { "os": "win32", "cpu": "x64" }, "sha512-7bVvO987daXGSJVYBoG8R4Q+csT1pKIgLJYZevXRQ0Hqw0Vv4mKme/TOjYXs9Qb1xMKh51Tb3bXKDbd8/4G08g=="],
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-nTodweTbPmkvwMu/a55XvjMsPtuyUSC+sV7f/SR57K36rB2I0YG21qNETN+00LOTUW9B3omd8XkiXJkt4kx/cw=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],

View File

@@ -41,7 +41,7 @@
"drizzle-kit": "^0.31.4",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.3"
"turbo": "2.7.4"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [