feat(sidebar): sidebar toggle and search (#700)

* fix: sidebar toggle

* feat: search complete
This commit is contained in:
Emir Karabeg
2025-07-15 20:41:20 -07:00
committed by GitHub
parent ca4b483ce3
commit b13f339327
8 changed files with 960 additions and 176 deletions
@@ -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>
)
}
@@ -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} />
</>
)
}
+1 -1
View File
@@ -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