mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(sidebar): sidebar toggle and search (#700)
* fix: sidebar toggle * feat: search complete
This commit is contained in:
@@ -43,6 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
state: templates.state,
|
||||
color: templates.color,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
@@ -80,6 +81,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
state: templateData.state,
|
||||
color: templateData.color,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
@@ -40,9 +41,13 @@ import {
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
|
||||
// Icon mapping for template icons
|
||||
const iconMap = {
|
||||
// Content & Documentation
|
||||
@@ -120,10 +125,11 @@ interface TemplateCardProps {
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
// Add handlers for star and use actions
|
||||
onStar?: (templateId: string, isCurrentlyStarred: boolean) => Promise<void>
|
||||
onUse?: (templateId: string) => Promise<void>
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
@@ -225,10 +231,18 @@ export function TemplateCard({
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
onStar,
|
||||
onUse,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
@@ -238,19 +252,98 @@ export function TemplateCard({
|
||||
// Get the icon component
|
||||
const iconComponent = getIconComponent(icon)
|
||||
|
||||
// Handle star toggle
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onStar) {
|
||||
await onStar(id, isStarred)
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use template
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onUse) {
|
||||
await onUse(id)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Call the callback if provided (for closing modals, etc.)
|
||||
if (onTemplateUsed) {
|
||||
onTemplateUsed()
|
||||
}
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +358,7 @@ export function TemplateCard({
|
||||
{/* Left side - Info */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section */}
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Icon container */}
|
||||
@@ -293,10 +386,11 @@ export function TemplateCard({
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors',
|
||||
isStarred
|
||||
'h-4 w-4 cursor-pointer transition-all duration-200',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
@@ -319,7 +413,7 @@ export function TemplateCard({
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<span className='flex-shrink-0'>by</span>
|
||||
<span className='min-w-0 truncate'>{author}</span>
|
||||
<span className='flex-shrink-0'>•</span>
|
||||
@@ -329,7 +423,7 @@ export function TemplateCard({
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<span>•</span>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{stars}</span>
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,75 +90,18 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateClick = (templateId: string) => {
|
||||
// Navigate to template detail page
|
||||
router.push(`/workspace/${params.workspaceId}/templates/${templateId}`)
|
||||
}
|
||||
|
||||
// Handle using a template
|
||||
const handleUseTemplate = async (templateId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// TODO: Open create template modal or navigate to create page
|
||||
console.log('Create new template')
|
||||
}
|
||||
|
||||
// Handle starring/unstarring templates (client-side for interactivity)
|
||||
const handleStarToggle = async (templateId: string, isCurrentlyStarred: boolean) => {
|
||||
try {
|
||||
const method = isCurrentlyStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${templateId}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state optimistically
|
||||
setTemplates((prev) =>
|
||||
prev.map((template) =>
|
||||
template.id === templateId
|
||||
? {
|
||||
...template,
|
||||
isStarred: !isCurrentlyStarred,
|
||||
stars: isCurrentlyStarred ? template.stars - 1 : template.stars + 1,
|
||||
}
|
||||
: template
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
}
|
||||
// Handle star change callback from template card
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
|
||||
@@ -201,10 +144,8 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
onStar={handleStarToggle}
|
||||
onUse={handleUseTemplate}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { BookOpen, LibraryBig, ScrollText, Search, Shapes } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '../../../templates/components/template-card'
|
||||
import { getKeyboardShortcutText } from '../../hooks/use-keyboard-shortcuts'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
templates?: TemplateData[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface TemplateData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
usageCount: string
|
||||
stars: number
|
||||
icon: string
|
||||
iconColor: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
isStarred?: boolean
|
||||
}
|
||||
|
||||
interface BlockItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface DocItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
type: 'main' | 'block' | 'tool'
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates = [],
|
||||
loading = false,
|
||||
}: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Local state for templates to handle star changes
|
||||
const [localTemplates, setLocalTemplates] = useState<TemplateData[]>(templates)
|
||||
|
||||
// Update local templates when props change
|
||||
useEffect(() => {
|
||||
setLocalTemplates(templates)
|
||||
}, [templates])
|
||||
|
||||
// Refs for synchronized scrolling
|
||||
const blocksRow1Ref = useRef<HTMLDivElement>(null)
|
||||
const blocksRow2Ref = useRef<HTMLDivElement>(null)
|
||||
const toolsRow1Ref = useRef<HTMLDivElement>(null)
|
||||
const toolsRow2Ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Synchronized scrolling functions
|
||||
const handleBlocksRow1Scroll = useCallback(() => {
|
||||
if (blocksRow1Ref.current && blocksRow2Ref.current) {
|
||||
blocksRow2Ref.current.scrollLeft = blocksRow1Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBlocksRow2Scroll = useCallback(() => {
|
||||
if (blocksRow1Ref.current && blocksRow2Ref.current) {
|
||||
blocksRow1Ref.current.scrollLeft = blocksRow2Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToolsRow1Scroll = useCallback(() => {
|
||||
if (toolsRow1Ref.current && toolsRow2Ref.current) {
|
||||
toolsRow2Ref.current.scrollLeft = toolsRow1Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToolsRow2Scroll = useCallback(() => {
|
||||
if (toolsRow1Ref.current && toolsRow2Ref.current) {
|
||||
toolsRow1Ref.current.scrollLeft = toolsRow2Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get all available blocks
|
||||
const blocks = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
.filter(
|
||||
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
|
||||
)
|
||||
.map(
|
||||
(block): BlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Get all available tools
|
||||
const tools = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
.filter((block) => block.category === 'tools')
|
||||
.map(
|
||||
(block): ToolItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Define pages
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
icon: LibraryBig,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
shortcut: getKeyboardShortcutText('K', true, true),
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Docs',
|
||||
icon: BookOpen,
|
||||
href: 'https://docs.simstudio.ai/',
|
||||
},
|
||||
],
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
// Define docs
|
||||
const docs = useMemo((): DocItem[] => {
|
||||
const allBlocks = getAllBlocks()
|
||||
const docsItems: DocItem[] = []
|
||||
|
||||
// Add individual block/tool docs
|
||||
allBlocks.forEach((block) => {
|
||||
if (block.docsLink) {
|
||||
docsItems.push({
|
||||
id: `docs-${block.type}`,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
href: block.docsLink,
|
||||
type: block.category === 'blocks' ? 'block' : 'tool',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return docsItems.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Filter all items based on search query
|
||||
const filteredBlocks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return blocks
|
||||
const query = searchQuery.toLowerCase()
|
||||
return blocks.filter((block) => block.name.toLowerCase().includes(query))
|
||||
}, [blocks, searchQuery])
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tools
|
||||
const query = searchQuery.toLowerCase()
|
||||
return tools.filter((tool) => tool.name.toLowerCase().includes(query))
|
||||
}, [tools, searchQuery])
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!searchQuery.trim()) return localTemplates.slice(0, 8)
|
||||
const query = searchQuery.toLowerCase()
|
||||
return localTemplates
|
||||
.filter(
|
||||
(template) =>
|
||||
template.title.toLowerCase().includes(query) ||
|
||||
template.description.toLowerCase().includes(query)
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [localTemplates, searchQuery])
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!searchQuery.trim()) return pages
|
||||
const query = searchQuery.toLowerCase()
|
||||
return pages.filter((page) => page.name.toLowerCase().includes(query))
|
||||
}, [pages, searchQuery])
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
if (!searchQuery.trim()) return docs
|
||||
const query = searchQuery.toLowerCase()
|
||||
return docs.filter((doc) => doc.name.toLowerCase().includes(query))
|
||||
}, [docs, searchQuery])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Clear search when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle block/tool click (same as toolbar interaction)
|
||||
const handleBlockClick = useCallback(
|
||||
(blockType: string) => {
|
||||
// Dispatch a custom event to be caught by the workflow component
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: {
|
||||
type: blockType,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
// Handle page navigation
|
||||
const handlePageClick = useCallback(
|
||||
(href: string) => {
|
||||
// External links open in new tab
|
||||
if (href.startsWith('http')) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(href)
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
[router, onOpenChange]
|
||||
)
|
||||
|
||||
// Handle docs navigation
|
||||
const handleDocsClick = useCallback(
|
||||
(href: string) => {
|
||||
// External links open in new tab
|
||||
if (href.startsWith('http')) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(href)
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
[router, onOpenChange]
|
||||
)
|
||||
|
||||
// Handle page navigation shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle shortcuts when modal is open
|
||||
if (!open) return
|
||||
|
||||
// Don't trigger if user is typing in the search input
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) return
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey
|
||||
|
||||
if (isModifierPressed && e.shiftKey) {
|
||||
// Command+Shift+L - Navigate to Logs
|
||||
if (e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault()
|
||||
handlePageClick(`/workspace/${workspaceId}/logs`)
|
||||
}
|
||||
// Command+Shift+K - Navigate to Knowledge
|
||||
else if (e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
handlePageClick(`/workspace/${workspaceId}/knowledge`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handlePageClick, workspaceId])
|
||||
|
||||
// Handle template usage callback (closes modal after template is used)
|
||||
const handleTemplateUsed = useCallback(() => {
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
// Handle star change callback from template card
|
||||
const handleStarChange = useCallback(
|
||||
(templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setLocalTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className='w-80 flex-shrink-0'>
|
||||
<TemplateCardSkeleton />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
className='bg-white/50 dark:bg-black/50'
|
||||
style={{ backdropFilter: 'blur(4.8px)' }}
|
||||
/>
|
||||
<DialogPrimitive.Content className='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 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-xl border border-border bg-background p-0 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in'>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
{/* Header with search input */}
|
||||
<div className='flex items-center border-b px-6 py-2'>
|
||||
<Search className='h-5 w-5 font-sans text-muted-foreground text-xl' />
|
||||
<Input
|
||||
placeholder='Search anything'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='!font-[300] !text-lg placeholder:!text-lg border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className='scrollbar-none flex-1 overflow-y-auto'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className='space-y-6 pt-6 pb-6'>
|
||||
{/* Blocks Section */}
|
||||
{filteredBlocks.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Blocks
|
||||
</h3>
|
||||
<div className='space-y-2'>
|
||||
{/* First row */}
|
||||
<div
|
||||
ref={blocksRow1Ref}
|
||||
onScroll={handleBlocksRow1Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredBlocks
|
||||
.slice(0, Math.ceil(filteredBlocks.length / 2))
|
||||
.map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => handleBlockClick(block.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: block.bgColor }}
|
||||
>
|
||||
<block.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{block.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Second row */}
|
||||
{filteredBlocks.length > Math.ceil(filteredBlocks.length / 2) && (
|
||||
<div
|
||||
ref={blocksRow2Ref}
|
||||
onScroll={handleBlocksRow2Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredBlocks.slice(Math.ceil(filteredBlocks.length / 2)).map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => handleBlockClick(block.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: block.bgColor }}
|
||||
>
|
||||
<block.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{block.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Section */}
|
||||
{filteredTools.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Tools
|
||||
</h3>
|
||||
<div className='space-y-2'>
|
||||
{/* First row */}
|
||||
<div
|
||||
ref={toolsRow1Ref}
|
||||
onScroll={handleToolsRow1Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredTools.slice(0, Math.ceil(filteredTools.length / 2)).map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => handleBlockClick(tool.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: tool.bgColor }}
|
||||
>
|
||||
<tool.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Second row */}
|
||||
{filteredTools.length > Math.ceil(filteredTools.length / 2) && (
|
||||
<div
|
||||
ref={toolsRow2Ref}
|
||||
onScroll={handleToolsRow2Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredTools.slice(Math.ceil(filteredTools.length / 2)).map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => handleBlockClick(tool.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: tool.bgColor }}
|
||||
>
|
||||
<tool.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Section */}
|
||||
{(loading || filteredTemplates.length > 0) && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Templates
|
||||
</h3>
|
||||
<div
|
||||
className='scrollbar-none flex gap-4 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: filteredTemplates.map((template) => (
|
||||
<div key={template.id} className='w-80 flex-shrink-0'>
|
||||
<TemplateCard
|
||||
id={template.id}
|
||||
title={template.title}
|
||||
description={template.description}
|
||||
author={template.author}
|
||||
usageCount={template.usageCount}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.iconColor}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onTemplateUsed={handleTemplateUsed}
|
||||
onStarChange={handleStarChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pages Section */}
|
||||
{filteredPages.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Pages
|
||||
</h3>
|
||||
<div className='space-y-1 px-6'>
|
||||
{filteredPages.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
onClick={() => handlePageClick(page.href)}
|
||||
className='flex h-10 w-full items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent/60 focus:bg-accent/60 focus:outline-none'
|
||||
>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<page.icon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
<span className='flex-1 text-left font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{page.name}
|
||||
</span>
|
||||
{page.shortcut && (
|
||||
<kbd className='flex h-6 w-10 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='pb-[4px] text-lg'>⇧</span>
|
||||
<span className='text-xs'>{page.shortcut.slice(-1)}</span>
|
||||
</span>
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docs Section */}
|
||||
{filteredDocs.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Docs
|
||||
</h3>
|
||||
<div className='space-y-1 px-6'>
|
||||
{filteredDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => handleDocsClick(doc.href)}
|
||||
className='flex h-10 w-full items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent/60 focus:bg-accent/60 focus:outline-none'
|
||||
>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<doc.icon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
<span className='flex-1 text-left font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{doc.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{searchQuery &&
|
||||
!loading &&
|
||||
filteredBlocks.length === 0 &&
|
||||
filteredTools.length === 0 &&
|
||||
filteredTemplates.length === 0 &&
|
||||
filteredPages.length === 0 &&
|
||||
filteredDocs.length === 0 && (
|
||||
<div className='ml-6 py-12 text-center'>
|
||||
<p className='text-muted-foreground'>No results found for "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+3
-7
@@ -29,6 +29,7 @@ interface WorkspaceHeaderProps {
|
||||
onCreateWorkflow: () => void
|
||||
isWorkspaceSelectorVisible: boolean
|
||||
onToggleWorkspaceSelector: () => void
|
||||
onToggleSidebar: () => void
|
||||
activeWorkspace: Workspace | null
|
||||
isWorkspacesLoading: boolean
|
||||
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
|
||||
@@ -42,6 +43,7 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
onCreateWorkflow,
|
||||
isWorkspaceSelectorVisible,
|
||||
onToggleWorkspaceSelector,
|
||||
onToggleSidebar,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
updateWorkspaceName,
|
||||
@@ -83,12 +85,6 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
}
|
||||
}, [isEditingName])
|
||||
|
||||
// Handle toggle sidebar
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
// This will be implemented when needed - placeholder for now
|
||||
logger.info('Toggle sidebar clicked')
|
||||
}, [])
|
||||
|
||||
// Handle workspace name click
|
||||
const handleWorkspaceNameClick = useCallback(() => {
|
||||
setEditingName(displayName)
|
||||
@@ -248,7 +244,7 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleToggleSidebar}
|
||||
onClick={onToggleSidebar}
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
>
|
||||
<PanelLeft className='h-4 w-4' />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import { SearchModal } from '../search-modal/search-modal'
|
||||
import { CreateMenu } from './components/create-menu/create-menu'
|
||||
import { FolderTree } from './components/folder-tree/folder-tree'
|
||||
import { HelpModal } from './components/help-modal/help-modal'
|
||||
@@ -51,6 +52,24 @@ interface Workspace {
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Template data interface for search modal
|
||||
*/
|
||||
interface TemplateData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
usageCount: string
|
||||
stars: number
|
||||
icon: string
|
||||
iconColor: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
isStarred?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
useGlobalShortcuts()
|
||||
|
||||
@@ -67,12 +86,18 @@ export function Sidebar() {
|
||||
|
||||
// Add state to prevent multiple simultaneous workflow creations
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
// Add sidebar collapsed state
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
// Template data for search modal
|
||||
const [templates, setTemplates] = useState<TemplateData[]>([])
|
||||
const [isTemplatesLoading, setIsTemplatesLoading] = useState(false)
|
||||
|
||||
// Refs
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
@@ -348,6 +373,60 @@ export function Sidebar() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Fetch popular templates for search modal
|
||||
*/
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setIsTemplatesLoading(true)
|
||||
try {
|
||||
// Fetch templates from API, ordered by views (most popular first)
|
||||
const response = await fetch('/api/templates?limit=8&offset=0')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch templates: ${response.status}`)
|
||||
}
|
||||
|
||||
const apiResponse = await response.json()
|
||||
|
||||
// Map API response to TemplateData format
|
||||
const fetchedTemplates: TemplateData[] =
|
||||
apiResponse.data?.map((template: any) => ({
|
||||
id: template.id,
|
||||
title: template.name,
|
||||
description: template.description || '',
|
||||
author: template.author,
|
||||
usageCount: formatUsageCount(template.views || 0),
|
||||
stars: template.stars || 0,
|
||||
icon: template.icon || 'FileText',
|
||||
iconColor: template.color || '#6B7280',
|
||||
state: template.state,
|
||||
isStarred: template.isStarred || false,
|
||||
})) || []
|
||||
|
||||
setTemplates(fetchedTemplates)
|
||||
logger.info(`Templates loaded successfully: ${fetchedTemplates.length} templates`)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching templates:', error)
|
||||
// Set empty array on error
|
||||
setTemplates([])
|
||||
} finally {
|
||||
setIsTemplatesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Format usage count for display (e.g., 1500 -> "1.5k")
|
||||
*/
|
||||
const formatUsageCount = (count: number): string => {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}m`
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// Load workflows for the current workspace when workspaceId changes
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
@@ -368,6 +447,7 @@ export function Sidebar() {
|
||||
if (sessionData?.user?.id && !isInitializedRef.current) {
|
||||
isInitializedRef.current = true
|
||||
fetchWorkspaces()
|
||||
fetchTemplates()
|
||||
}
|
||||
}, [sessionData?.user?.id]) // Removed fetchWorkspaces dependency
|
||||
|
||||
@@ -391,7 +471,7 @@ export function Sidebar() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showInviteMembers, setShowInviteMembers] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showSearchModal, setShowSearchModal] = useState(false)
|
||||
|
||||
// Separate regular workflows from temporary marketplace workflows
|
||||
const { regularWorkflows, tempWorkflows } = useMemo(() => {
|
||||
@@ -456,6 +536,15 @@ export function Sidebar() {
|
||||
setIsWorkspaceSelectorVisible((prev) => !prev)
|
||||
}
|
||||
|
||||
// Toggle sidebar collapsed state
|
||||
const toggleSidebarCollapsed = () => {
|
||||
setIsSidebarCollapsed((prev) => !prev)
|
||||
// Hide workspace selector when collapsing sidebar
|
||||
if (!isSidebarCollapsed) {
|
||||
setIsWorkspaceSelectorVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate dynamic positions for floating elements
|
||||
const calculateFloatingPositions = useCallback(() => {
|
||||
const { CONTAINER_PADDING, WORKSPACE_HEADER, SEARCH, WORKFLOW_SELECTOR, WORKSPACE_SELECTOR } =
|
||||
@@ -467,13 +556,15 @@ export function Sidebar() {
|
||||
// Add workspace header
|
||||
currentTop += WORKSPACE_HEADER + SIDEBAR_GAP
|
||||
|
||||
// Add workspace selector if visible
|
||||
if (isWorkspaceSelectorVisible) {
|
||||
// Add workspace selector if visible and not collapsed
|
||||
if (isWorkspaceSelectorVisible && !isSidebarCollapsed) {
|
||||
currentTop += WORKSPACE_SELECTOR + SIDEBAR_GAP
|
||||
}
|
||||
|
||||
// Add search
|
||||
currentTop += SEARCH + SIDEBAR_GAP
|
||||
// Add search (if not collapsed)
|
||||
if (!isSidebarCollapsed) {
|
||||
currentTop += SEARCH + SIDEBAR_GAP
|
||||
}
|
||||
|
||||
// Add workflow selector
|
||||
currentTop += WORKFLOW_SELECTOR - 4
|
||||
@@ -488,10 +579,41 @@ export function Sidebar() {
|
||||
toolbarTop,
|
||||
navigationBottom,
|
||||
}
|
||||
}, [isWorkspaceSelectorVisible])
|
||||
}, [isWorkspaceSelectorVisible, isSidebarCollapsed])
|
||||
|
||||
const { toolbarTop, navigationBottom } = calculateFloatingPositions()
|
||||
|
||||
// Add keyboard shortcut for search modal (Cmd+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't trigger if user is typing in an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) return
|
||||
|
||||
// Cmd/Ctrl + K - Open search modal
|
||||
if (
|
||||
event.key.toLowerCase() === 'k' &&
|
||||
((event.metaKey &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.platform.toUpperCase().indexOf('MAC') >= 0) ||
|
||||
(event.ctrlKey &&
|
||||
(typeof navigator === 'undefined' ||
|
||||
navigator.platform.toUpperCase().indexOf('MAC') < 0)))
|
||||
) {
|
||||
event.preventDefault()
|
||||
setShowSearchModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Navigation items with their respective actions
|
||||
const navigationItems = [
|
||||
{
|
||||
@@ -527,7 +649,6 @@ export function Sidebar() {
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
tooltip: 'Templates',
|
||||
shortcut: getKeyboardShortcutText('T', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
]
|
||||
@@ -546,6 +667,7 @@ export function Sidebar() {
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
onToggleWorkspaceSelector={toggleWorkspaceSelector}
|
||||
onToggleSidebar={toggleSidebarCollapsed}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
updateWorkspaceName={updateWorkspaceName}
|
||||
@@ -553,44 +675,52 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* 2. Workspace Selector - Conditionally rendered */}
|
||||
{isWorkspaceSelectorVisible && (
|
||||
<div className='pointer-events-auto flex-shrink-0'>
|
||||
<WorkspaceSelector
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
onWorkspaceUpdate={refreshWorkspaceList}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`pointer-events-auto flex-shrink-0 ${
|
||||
!isWorkspaceSelectorVisible || isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<WorkspaceSelector
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
onWorkspaceUpdate={refreshWorkspaceList}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. Search */}
|
||||
{/* <div className='pointer-events-auto flex-shrink-0'>
|
||||
<div className='flex h-12 items-center gap-2 rounded-[14px] border bg-card pr-2 pl-3 shadow-xs'>
|
||||
<div
|
||||
className={`pointer-events-auto flex-shrink-0 ${isSidebarCollapsed ? 'hidden' : ''}`}
|
||||
>
|
||||
<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'
|
||||
>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search anything'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='h-8 flex-1 border-0 bg-transparent px-0 font-normal text-base text-muted-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<span className='flex h-8 flex-1 items-center px-0 font-[350] 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>
|
||||
</div>
|
||||
</div> */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 4. Workflow Selector */}
|
||||
<div className='pointer-events-auto relative h-[272px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs'>
|
||||
<div
|
||||
className={`pointer-events-auto relative h-[212px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs ${
|
||||
isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className='px-2'>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[270px]' hideScrollbar={true}>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[212px]' hideScrollbar={true}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
@@ -614,20 +744,20 @@ export function Sidebar() {
|
||||
</aside>
|
||||
|
||||
{/* Floating Toolbar - Only on workflow pages */}
|
||||
{isOnWorkflowPage && (
|
||||
<div
|
||||
className='pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs'
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
userPermissions={userPermissions}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs ${
|
||||
!isOnWorkflowPage || isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
userPermissions={userPermissions}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating Navigation - Always visible */}
|
||||
<div
|
||||
@@ -645,6 +775,7 @@ export function Sidebar() {
|
||||
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
<SearchModal open={showSearchModal} onOpenChange={setShowSearchModal} templates={templates} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LinkupBlock: BlockConfig<LinkupSearchToolResponse> = {
|
||||
'Linkup Search allows you to search and retrieve up-to-date information from the web with source attribution.',
|
||||
docsLink: 'https://docs.simstudio.ai/tools/linkup',
|
||||
category: 'tools',
|
||||
bgColor: '#EAEADC',
|
||||
bgColor: '#D6D3C7',
|
||||
icon: LinkupIcon,
|
||||
|
||||
subBlocks: [
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user