fix(invite): fixed invite modal, fix search modal keyboard nav (#879)

* fix(invite): fixed invite modal

* fix(search-modal): fix search modal keyboard nav
This commit is contained in:
Waleed Latif
2025-08-05 12:19:11 -07:00
committed by GitHub
parent 4880d34786
commit 6ec5cf46e2
3 changed files with 666 additions and 517 deletions

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface NavigationSection {
id: string
name: string
type: 'grid' | 'list'
items: any[]
gridCols?: number // How many columns per row for grid sections
}
export interface NavigationPosition {
sectionIndex: number
itemIndex: number
}
export function useSearchNavigation(sections: NavigationSection[], isOpen: boolean) {
const [position, setPosition] = useState<NavigationPosition>({ sectionIndex: 0, itemIndex: 0 })
const scrollRefs = useRef<Map<string, HTMLElement>>(new Map())
const lastPositionInSection = useRef<Map<string, number>>(new Map())
// Reset position when sections change or modal opens
useEffect(() => {
if (sections.length > 0) {
setPosition({ sectionIndex: 0, itemIndex: 0 })
}
}, [sections, isOpen])
const getCurrentItem = useCallback(() => {
if (sections.length === 0 || position.sectionIndex >= sections.length) return null
const section = sections[position.sectionIndex]
if (position.itemIndex >= section.items.length) return null
return {
section,
item: section.items[position.itemIndex],
position,
}
}, [sections, position])
const navigate = useCallback(
(direction: 'up' | 'down' | 'left' | 'right') => {
if (sections.length === 0) return
const currentSection = sections[position.sectionIndex]
if (!currentSection) return
const isGridSection = currentSection.type === 'grid'
const gridCols = currentSection.gridCols || 1
setPosition((prevPosition) => {
let newSectionIndex = prevPosition.sectionIndex
let newItemIndex = prevPosition.itemIndex
if (direction === 'up') {
if (isGridSection) {
// In grid: up moves to previous row in same section, or previous section
if (newItemIndex >= gridCols) {
newItemIndex -= gridCols
} else if (newSectionIndex > 0) {
// Save current position before moving to previous section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
// Move to previous section
newSectionIndex -= 1
const prevSection = sections[newSectionIndex]
// Restore last position in that section, or go to end
const lastPos = lastPositionInSection.current.get(prevSection.id)
if (lastPos !== undefined && lastPos < prevSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = Math.max(0, prevSection.items.length - 1)
}
}
} else {
// In list: up moves to previous item, or previous section
if (newItemIndex > 0) {
newItemIndex -= 1
} else if (newSectionIndex > 0) {
// Save current position before moving to previous section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
newSectionIndex -= 1
const prevSection = sections[newSectionIndex]
// Restore last position in that section, or go to end
const lastPos = lastPositionInSection.current.get(prevSection.id)
if (lastPos !== undefined && lastPos < prevSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = Math.max(0, prevSection.items.length - 1)
}
}
}
} else if (direction === 'down') {
if (isGridSection) {
// In grid: down moves to next row in same section, or next section
const maxIndexInCurrentRow = Math.min(
newItemIndex + gridCols,
currentSection.items.length - 1
)
if (newItemIndex + gridCols < currentSection.items.length) {
newItemIndex += gridCols
} else if (newSectionIndex < sections.length - 1) {
// Save current position before moving to next section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
// Move to next section
newSectionIndex += 1
const nextSection = sections[newSectionIndex]
// Restore last position in next section, or start at beginning
const lastPos = lastPositionInSection.current.get(nextSection.id)
if (lastPos !== undefined && lastPos < nextSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = 0
}
}
} else {
// In list: down moves to next item, or next section
if (newItemIndex < currentSection.items.length - 1) {
newItemIndex += 1
} else if (newSectionIndex < sections.length - 1) {
// Save current position before moving to next section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
newSectionIndex += 1
const nextSection = sections[newSectionIndex]
// Restore last position in next section, or start at beginning
const lastPos = lastPositionInSection.current.get(nextSection.id)
if (lastPos !== undefined && lastPos < nextSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = 0
}
}
}
} else if (direction === 'left' && isGridSection) {
// In grid: left moves to previous item in same row
if (newItemIndex > 0) {
const currentRow = Math.floor(newItemIndex / gridCols)
const newIndex = newItemIndex - 1
const newRow = Math.floor(newIndex / gridCols)
// Only move if we stay in the same row
if (currentRow === newRow) {
newItemIndex = newIndex
}
}
} else if (direction === 'right' && isGridSection) {
// In grid: right moves to next item in same row
if (newItemIndex < currentSection.items.length - 1) {
const currentRow = Math.floor(newItemIndex / gridCols)
const newIndex = newItemIndex + 1
const newRow = Math.floor(newIndex / gridCols)
// Only move if we stay in the same row
if (currentRow === newRow) {
newItemIndex = newIndex
}
}
}
return { sectionIndex: newSectionIndex, itemIndex: newItemIndex }
})
},
[sections, position]
)
// Scroll selected item into view
useEffect(() => {
const current = getCurrentItem()
if (!current) return
const { section, position: currentPos } = current
const scrollContainer = scrollRefs.current.get(section.id)
if (scrollContainer) {
const itemElement = scrollContainer.querySelector(
`[data-nav-item="${section.id}-${currentPos.itemIndex}"]`
) as HTMLElement
if (itemElement) {
// For horizontal scrolling sections (blocks/tools)
if (section.type === 'grid') {
const containerRect = scrollContainer.getBoundingClientRect()
const itemRect = itemElement.getBoundingClientRect()
// Check if item is outside the visible area horizontally
if (itemRect.left < containerRect.left) {
scrollContainer.scrollTo({
left: scrollContainer.scrollLeft - (containerRect.left - itemRect.left + 20),
behavior: 'smooth',
})
} else if (itemRect.right > containerRect.right) {
scrollContainer.scrollTo({
left: scrollContainer.scrollLeft + (itemRect.right - containerRect.right + 20),
behavior: 'smooth',
})
}
}
// Always ensure vertical visibility
itemElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
}, [getCurrentItem, position])
return {
navigate,
getCurrentItem,
scrollRefs,
position,
}
}

View File

@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'
import { getAllBlocks } from '@/blocks'
import { TemplateCard, TemplateCardSkeleton } from '../../../templates/components/template-card'
import { getKeyboardShortcutText } from '../../hooks/use-keyboard-shortcuts'
import { type NavigationSection, useSearchNavigation } from './hooks/use-search-navigation'
interface SearchModalProps {
open: boolean
@@ -96,7 +97,6 @@ export function SearchModal({
isOnWorkflowPage = false,
}: SearchModalProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
@@ -258,74 +258,69 @@ export function SearchModal({
return docs.filter((doc) => doc.name.toLowerCase().includes(query))
}, [docs, searchQuery])
// Create flattened list of navigatable items for keyboard navigation
const navigatableItems = useMemo(() => {
const items: Array<{
type: 'workspace' | 'workflow' | 'page' | 'doc' | 'block' | 'tool'
data: any
section: string
}> = []
// Create navigation sections for keyboard navigation
const navigationSections = useMemo((): NavigationSection[] => {
const sections: NavigationSection[] = []
// Add blocks first (highest priority)
filteredBlocks.forEach((block) => {
items.push({ type: 'block', data: block, section: 'Blocks' })
})
if (filteredBlocks.length > 0) {
sections.push({
id: 'blocks',
name: 'Blocks',
type: 'grid',
items: filteredBlocks,
gridCols: filteredBlocks.length, // Single row - all items in one row
})
}
// Add tools second
filteredTools.forEach((tool) => {
items.push({ type: 'tool', data: tool, section: 'Tools' })
})
if (filteredTools.length > 0) {
sections.push({
id: 'tools',
name: 'Tools',
type: 'grid',
items: filteredTools,
gridCols: filteredTools.length, // Single row - all items in one row
})
}
// Skip templates for now
if (filteredTemplates.length > 0) {
sections.push({
id: 'templates',
name: 'Templates',
type: 'grid',
items: filteredTemplates,
gridCols: filteredTemplates.length, // Single row - all templates in one row
})
}
// Add workspaces
filteredWorkspaces.forEach((workspace) => {
items.push({ type: 'workspace', data: workspace, section: 'Workspaces' })
})
// Combine all list items into one section
const listItems = [
...filteredWorkspaces.map((item) => ({ type: 'workspace', data: item })),
...filteredWorkflows.map((item) => ({ type: 'workflow', data: item })),
...filteredPages.map((item) => ({ type: 'page', data: item })),
...filteredDocs.map((item) => ({ type: 'doc', data: item })),
]
// Add workflows
filteredWorkflows.forEach((workflow) => {
items.push({ type: 'workflow', data: workflow, section: 'Workflows' })
})
if (listItems.length > 0) {
sections.push({
id: 'list',
name: 'Navigation',
type: 'list',
items: listItems,
})
}
// Add pages
filteredPages.forEach((page) => {
items.push({ type: 'page', data: page, section: 'Pages' })
})
// Add docs
filteredDocs.forEach((doc) => {
items.push({ type: 'doc', data: doc, section: 'Docs' })
})
return items
return sections
}, [
filteredBlocks,
filteredTools,
filteredTemplates,
filteredWorkspaces,
filteredWorkflows,
filteredPages,
filteredDocs,
])
// Reset selected index when items change or modal opens
useEffect(() => {
setSelectedIndex(0)
}, [navigatableItems, open])
// 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])
const { navigate, getCurrentItem, scrollRefs } = useSearchNavigation(navigationSections, open)
// Clear search when modal closes
useEffect(() => {
@@ -449,14 +444,20 @@ export function SearchModal({
[]
)
// Handle item selection based on type
const handleItemSelection = useCallback(
(item: (typeof navigatableItems)[0]) => {
// Handle item selection based on current item
const handleItemSelection = useCallback(() => {
const current = getCurrentItem()
if (!current) return
const { section, item } = current
if (section.id === 'blocks' || section.id === 'tools') {
handleBlockClick(item.type)
} else if (section.id === 'templates') {
// Templates don't have direct selection, but we close the modal
onOpenChange(false)
} else if (section.id === 'list') {
switch (item.type) {
case 'block':
case 'tool':
handleBlockClick(item.data.type)
break
case 'workspace':
if (item.data.isCurrent) {
onOpenChange(false)
@@ -478,132 +479,41 @@ export function SearchModal({
handleDocsClick(item.data.href)
break
}
},
[handleBlockClick, handleNavigationClick, handlePageClick, handleDocsClick, onOpenChange]
)
// Get section boundaries for navigation
const getSectionBoundaries = useCallback(() => {
const boundaries: { [key: string]: { start: number; end: number } } = {}
let currentIndex = 0
const sections = ['Blocks', 'Tools', 'Workspaces', 'Workflows', 'Pages', 'Docs']
sections.forEach((section) => {
const sectionItems = navigatableItems.filter((item) => item.section === section)
if (sectionItems.length > 0) {
boundaries[section] = {
start: currentIndex,
end: currentIndex + sectionItems.length - 1,
}
currentIndex += sectionItems.length
}
})
return boundaries
}, [navigatableItems])
// Get current section from selected index
const getCurrentSection = useCallback(
(index: number) => {
const boundaries = getSectionBoundaries()
for (const [section, boundary] of Object.entries(boundaries)) {
if (index >= boundary.start && index <= boundary.end) {
return section
}
}
return null
},
[getSectionBoundaries]
)
}
}, [
getCurrentItem,
handleBlockClick,
handleNavigationClick,
handlePageClick,
handleDocsClick,
onOpenChange,
])
// Handle keyboard navigation
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
const boundaries = getSectionBoundaries()
const currentSection = getCurrentSection(selectedIndex)
// Check if we're in blocks or tools sections (special navigation)
const isInBlocksOrTools = currentSection === 'Blocks' || currentSection === 'Tools'
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (isInBlocksOrTools) {
// Jump to next section for blocks/tools
if (currentSection) {
const sections = Object.keys(boundaries)
const currentSectionIndex = sections.indexOf(currentSection)
if (currentSectionIndex < sections.length - 1) {
const nextSection = sections[currentSectionIndex + 1]
setSelectedIndex(boundaries[nextSection].start)
}
}
} else {
// Regular navigation within section for other sections
setSelectedIndex((prev) => Math.min(prev + 1, navigatableItems.length - 1))
}
navigate('down')
break
case 'ArrowUp':
e.preventDefault()
if (isInBlocksOrTools) {
// Jump to previous section for blocks/tools
if (currentSection) {
const sections = Object.keys(boundaries)
const currentSectionIndex = sections.indexOf(currentSection)
if (currentSectionIndex > 0) {
const prevSection = sections[currentSectionIndex - 1]
setSelectedIndex(boundaries[prevSection].start)
}
}
} else {
// Check if moving up would go into blocks or tools section
const newIndex = Math.max(selectedIndex - 1, 0)
const newSection = getCurrentSection(newIndex)
if (newSection === 'Blocks' || newSection === 'Tools') {
// Jump to start of the blocks/tools section
setSelectedIndex(boundaries[newSection].start)
} else {
// Regular navigation for other sections
setSelectedIndex(newIndex)
}
}
navigate('up')
break
case 'ArrowRight':
e.preventDefault()
if (isInBlocksOrTools) {
// Navigate within current section for blocks/tools
if (currentSection && boundaries[currentSection]) {
const { end } = boundaries[currentSection]
setSelectedIndex((prev) => Math.min(prev + 1, end))
}
} else {
// For other sections, right arrow does nothing or same as down
setSelectedIndex((prev) => Math.min(prev + 1, navigatableItems.length - 1))
}
navigate('right')
break
case 'ArrowLeft':
e.preventDefault()
if (isInBlocksOrTools) {
// Navigate within current section for blocks/tools
if (currentSection && boundaries[currentSection]) {
const { start } = boundaries[currentSection]
setSelectedIndex((prev) => Math.max(prev - 1, start))
}
} else {
// For other sections, left arrow does nothing or same as up
setSelectedIndex((prev) => Math.max(prev - 1, 0))
}
navigate('left')
break
case 'Enter':
e.preventDefault()
if (navigatableItems.length > 0 && selectedIndex < navigatableItems.length) {
const selectedItem = navigatableItems[selectedIndex]
handleItemSelection(selectedItem)
}
handleItemSelection()
break
case 'Escape':
onOpenChange(false)
@@ -613,69 +523,17 @@ export function SearchModal({
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [
open,
selectedIndex,
navigatableItems,
onOpenChange,
handleItemSelection,
getSectionBoundaries,
getCurrentSection,
])
}, [open, navigate, handleItemSelection, onOpenChange])
// Helper function to check if an item is selected
const isItemSelected = useCallback(
(item: any, itemType: string) => {
if (navigatableItems.length === 0 || selectedIndex >= navigatableItems.length) return false
const selectedItem = navigatableItems[selectedIndex]
return selectedItem.type === itemType && selectedItem.data.id === item.id
(sectionId: string, itemIndex: number) => {
const current = getCurrentItem()
return current?.section.id === sectionId && current.position.itemIndex === itemIndex
},
[navigatableItems, selectedIndex]
[getCurrentItem]
)
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && navigatableItems.length > 0) {
const selectedItem = navigatableItems[selectedIndex]
const itemElement = document.querySelector(
`[data-search-item="${selectedItem.type}-${selectedItem.data.id}"]`
)
if (itemElement) {
// Special handling for edge items in blocks/tools sections (horizontal scrolling)
if (selectedItem.type === 'block' || selectedItem.type === 'tool') {
const boundaries = getSectionBoundaries()
const isFirstBlock =
selectedItem.type === 'block' && selectedIndex === (boundaries.Blocks?.start ?? -1)
const isLastBlock =
selectedItem.type === 'block' && selectedIndex === (boundaries.Blocks?.end ?? -1)
const isFirstTool =
selectedItem.type === 'tool' && selectedIndex === (boundaries.Tools?.start ?? -1)
const isLastTool =
selectedItem.type === 'tool' && selectedIndex === (boundaries.Tools?.end ?? -1)
if (isFirstBlock || isFirstTool) {
// Find the horizontal scroll container and scroll to left
const container = itemElement.closest('.scrollbar-none.flex.gap-2.overflow-x-auto')
if (container) {
;(container as HTMLElement).scrollLeft = 0
}
} else if (isLastBlock || isLastTool) {
// Find the horizontal scroll container and scroll to right
const container = itemElement.closest('.scrollbar-none.flex.gap-2.overflow-x-auto')
if (container) {
const scrollContainer = container as HTMLElement
scrollContainer.scrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth
}
}
}
// Default behavior for all items (ensure they're in view vertically)
itemElement.scrollIntoView({ block: 'nearest' })
}
}
}, [selectedIndex, navigatableItems, getSectionBoundaries])
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
@@ -721,16 +579,19 @@ export function SearchModal({
Blocks
</h3>
<div
ref={(el) => {
if (el) scrollRefs.current.set('blocks', el)
}}
className='scrollbar-none flex gap-2 overflow-x-auto px-6 pb-1'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{filteredBlocks.map((block) => (
{filteredBlocks.map((block, index) => (
<button
key={block.id}
onClick={() => handleBlockClick(block.type)}
data-search-item={`block-${block.id}`}
data-nav-item={`blocks-${index}`}
className={`flex h-auto w-[180px] flex-shrink-0 cursor-pointer flex-col items-start gap-2 rounded-[8px] border p-3 transition-all duration-200 ${
isItemSelected(block, 'block')
isItemSelected('blocks', index)
? 'border-border bg-secondary/80'
: 'border-border/40 bg-background/60 hover:border-border hover:bg-secondary/80'
}`}
@@ -764,16 +625,19 @@ export function SearchModal({
Tools
</h3>
<div
ref={(el) => {
if (el) scrollRefs.current.set('tools', el)
}}
className='scrollbar-none flex gap-2 overflow-x-auto px-6 pb-1'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{filteredTools.map((tool) => (
{filteredTools.map((tool, index) => (
<button
key={tool.id}
onClick={() => handleBlockClick(tool.type)}
data-search-item={`tool-${tool.id}`}
data-nav-item={`tools-${index}`}
className={`flex h-auto w-[180px] flex-shrink-0 cursor-pointer flex-col items-start gap-2 rounded-[8px] border p-3 transition-all duration-200 ${
isItemSelected(tool, 'tool')
isItemSelected('tools', index)
? 'border-border bg-secondary/80'
: 'border-border/40 bg-background/60 hover:border-border hover:bg-secondary/80'
}`}
@@ -807,13 +671,22 @@ export function SearchModal({
Templates
</h3>
<div
ref={(el) => {
if (el) scrollRefs.current.set('templates', el)
}}
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'>
: filteredTemplates.map((template, index) => (
<div
key={template.id}
data-nav-item={`templates-${index}`}
className={`w-80 flex-shrink-0 rounded-lg transition-all duration-200 ${
isItemSelected('templates', index) ? 'opacity-75' : 'opacity-100'
}`}
>
<TemplateCard
id={template.id}
title={template.title}
@@ -834,134 +707,160 @@ export function SearchModal({
</div>
)}
{/* Workspaces Section */}
{filteredWorkspaces.length > 0 && (
<div>
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
Workspaces
</h3>
<div className='space-y-1 px-6'>
{filteredWorkspaces.map((workspace) => (
<button
key={workspace.id}
onClick={() =>
workspace.isCurrent
? onOpenChange(false)
: handleNavigationClick(workspace.href)
}
data-search-item={`workspace-${workspace.id}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected(workspace, 'workspace')
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<div className='flex h-5 w-5 items-center justify-center'>
<Building2 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'>
{workspace.name}
{workspace.isCurrent && ' (current)'}
</span>
</button>
))}
</div>
</div>
)}
{/* List sections (Workspaces, Workflows, Pages, Docs) */}
{navigationSections.find((s) => s.id === 'list') && (
<div
ref={(el) => {
if (el) scrollRefs.current.set('list', el)
}}
>
{/* Workspaces */}
{filteredWorkspaces.length > 0 && (
<div className='mb-6'>
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
Workspaces
</h3>
<div className='space-y-1 px-6'>
{filteredWorkspaces.map((workspace, workspaceIndex) => {
const globalIndex = workspaceIndex
return (
<button
key={workspace.id}
onClick={() =>
workspace.isCurrent
? onOpenChange(false)
: handleNavigationClick(workspace.href)
}
data-nav-item={`list-${globalIndex}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected('list', globalIndex)
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<div className='flex h-5 w-5 items-center justify-center'>
<Building2 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'>
{workspace.name}
{workspace.isCurrent && ' (current)'}
</span>
</button>
)
})}
</div>
</div>
)}
{/* Workflows Section */}
{filteredWorkflows.length > 0 && (
<div>
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
Workflows
</h3>
<div className='space-y-1 px-6'>
{filteredWorkflows.map((workflow) => (
<button
key={workflow.id}
onClick={() =>
workflow.isCurrent
? onOpenChange(false)
: handleNavigationClick(workflow.href)
}
data-search-item={`workflow-${workflow.id}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected(workflow, 'workflow')
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<div className='flex h-5 w-5 items-center justify-center'>
<Workflow 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'>
{workflow.name}
{workflow.isCurrent && ' (current)'}
</span>
</button>
))}
</div>
</div>
)}
{/* Workflows */}
{filteredWorkflows.length > 0 && (
<div className='mb-6'>
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
Workflows
</h3>
<div className='space-y-1 px-6'>
{filteredWorkflows.map((workflow, workflowIndex) => {
const globalIndex = filteredWorkspaces.length + workflowIndex
return (
<button
key={workflow.id}
onClick={() =>
workflow.isCurrent
? onOpenChange(false)
: handleNavigationClick(workflow.href)
}
data-nav-item={`list-${globalIndex}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected('list', globalIndex)
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<div className='flex h-5 w-5 items-center justify-center'>
<Workflow 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'>
{workflow.name}
{workflow.isCurrent && ' (current)'}
</span>
</button>
)
})}
</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)}
data-search-item={`page-${page.id}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected(page, 'page')
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<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 && <KeyboardShortcut shortcut={page.shortcut} />}
</button>
))}
</div>
</div>
)}
{/* Pages */}
{filteredPages.length > 0 && (
<div className='mb-6'>
<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, pageIndex) => {
const globalIndex =
filteredWorkspaces.length + filteredWorkflows.length + pageIndex
return (
<button
key={page.id}
onClick={() => handlePageClick(page.href)}
data-nav-item={`list-${globalIndex}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected('list', globalIndex)
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<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 && <KeyboardShortcut shortcut={page.shortcut} />}
</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)}
data-search-item={`doc-${doc.id}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected(doc, 'doc')
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<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>
{/* Docs */}
{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, docIndex) => {
const globalIndex =
filteredWorkspaces.length +
filteredWorkflows.length +
filteredPages.length +
docIndex
return (
<button
key={doc.id}
onClick={() => handleDocsClick(doc.href)}
data-nav-item={`list-${globalIndex}`}
className={`flex h-10 w-full items-center gap-3 rounded-[8px] px-3 py-2 transition-colors focus:outline-none ${
isItemSelected('list', globalIndex)
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/60 focus:bg-accent/60'
}`}
>
<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>
)}
</div>
)}

View File

@@ -1,6 +1,6 @@
'use client'
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { validateAndNormalizeEmail } from '@/lib/email/utils'
import { createLogger } from '@/lib/logs/console/logger'
@@ -86,7 +87,7 @@ const EmailTag = React.memo<EmailTagProps>(({ email, onRemove, disabled, isInval
: 'border bg-muted text-muted-foreground'
)}
>
<span className='max-w-[120px] truncate'>{email}</span>
<span className='max-w-[200px] truncate'>{email}</span>
{isSent && <span className='text-muted-foreground text-xs'>sent</span>}
{!disabled && !isSent && (
<button
@@ -308,56 +309,76 @@ const PermissionsTable = ({
return (
<div key={uniqueKey} className='flex items-center justify-between gap-2 py-2'>
{/* Email - truncated if needed */}
<span className='min-w-0 flex-1 truncate font-medium text-card-foreground text-sm'>
{user.email}
</span>
{/* Email and status badges */}
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className='inline-flex items-center rounded-[8px] bg-blue-100 px-2 py-1 font-medium text-blue-700 text-xs dark:bg-blue-900/30 dark:text-blue-400'>
Sent
</span>
)}
{hasChanges && (
<span className='inline-flex items-center rounded-[8px] bg-orange-100 px-2 py-1 font-medium text-orange-700 text-xs dark:bg-orange-900/30 dark:text-orange-400'>
Modified
</span>
)}
</div>
</div>
{/* Permission selector */}
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) => onPermissionChange(userIdentifier, newPermission)}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
className='w-auto flex-shrink-0'
/>
{/* X button - styled like workflow-item.tsx */}
{((canShowRemoveButton && onRemoveMember) ||
(isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onRemoveInvitation)) && (
<Button
variant='ghost'
size='icon'
onClick={() => {
if (canShowRemoveButton && onRemoveMember) {
onRemoveMember(user.userId!, user.email)
} else if (isPendingInvitation && user.invitationId && onRemoveInvitation) {
onRemoveInvitation(user.invitationId, user.email)
}
}}
disabled={disabled || isSaving}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
title={
isPendingInvitation
? `Cancel invitation for ${user.email}`
: `Remove ${user.email} from workspace`
{/* Permission selector and remove button container */}
<div className='flex flex-shrink-0 items-center gap-2'>
<PermissionSelector
value={user.permissionType}
onChange={(newPermission) => onPermissionChange(userIdentifier, newPermission)}
disabled={
disabled ||
!currentUserIsAdmin ||
isPendingInvitation ||
(isCurrentUser && user.permissionType === 'admin')
}
>
<X className='h-3.5 w-3.5' />
<span className='sr-only'>
{isPendingInvitation
? `Cancel invitation for ${user.email}`
: `Remove ${user.email}`}
</span>
</Button>
)}
className='w-auto'
/>
{/* X button with consistent spacing - always reserve space */}
<div className='flex h-4 w-4 items-center justify-center'>
{((canShowRemoveButton && onRemoveMember) ||
(isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onRemoveInvitation)) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (canShowRemoveButton && onRemoveMember) {
onRemoveMember(user.userId!, user.email)
} else if (
isPendingInvitation &&
user.invitationId &&
onRemoveInvitation
) {
onRemoveInvitation(user.invitationId, user.email)
}
}}
disabled={disabled || isSaving}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
<X className='h-3.5 w-3.5' />
<span className='sr-only'>
{isPendingInvitation ? 'Revoke invite' : 'Remove member'}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isPendingInvitation ? 'Revoke invite' : 'Remove member'}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
)
})}
@@ -368,9 +389,9 @@ const PermissionsTable = ({
}
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState<string[]>([])
const [sentEmails, setSentEmails] = useState<string[]>([])
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
@@ -404,8 +425,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
} = useWorkspacePermissionsContext()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const hasNewInvites =
emails.filter((email) => !sentEmails.includes(email)).length > 0 || inputValue.trim()
const hasNewInvites = emails.length > 0 || inputValue.trim()
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
@@ -837,16 +857,24 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
fetchPendingInvitations()
setInputValue('')
// Track which emails were successfully sent
const successfulEmails = emails.filter((email, index) => results[index])
setSentEmails((prev) => [...prev, ...successfulEmails])
if (failedInvites.length > 0) {
setEmails(failedInvites)
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmails([])
setUserPermissions([])
setTimeout(() => {
onOpenChange(false)
}, 1500)
}
setInvalidEmails([])
setShowSent(true)
setTimeout(() => {
setShowSent(false)
}, 4000)
}
} catch (err) {
logger.error('Error inviting members:', err)
@@ -873,7 +901,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
// Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setSentEmails([])
setInvalidEmails([])
setUserPermissions([])
setPendingInvitations([])
@@ -901,118 +928,122 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}}
>
<AlertDialogContent className='flex max-h-[80vh] flex-col gap-0 sm:max-w-[560px]'>
<AlertDialogHeader>
<AlertDialogTitle>Invite members to {workspaceName || 'Workspace'}</AlertDialogTitle>
</AlertDialogHeader>
<TooltipProvider>
<AlertDialogHeader>
<AlertDialogTitle>Invite members to {workspaceName || 'Workspace'}</AlertDialogTitle>
</AlertDialogHeader>
<form onSubmit={handleSubmit} className='mt-5'>
<div className='space-y-2'>
<label htmlFor='emails' className='font-medium text-sm'>
Email Addresses
</label>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => removeInvalidEmail(index)}
<form ref={formRef} onSubmit={handleSubmit} className='mt-5'>
<div className='space-y-2'>
<label htmlFor='emails' className='font-medium text-sm'>
Email Addresses
</label>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => removeInvalidEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => removeEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
/>
))}
<Input
id='emails'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
isInvalid={true}
/>
))}
{emails.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => removeEmail(index)}
disabled={isSubmitting || !userPerms.canAdmin}
isSent={sentEmails.includes(email)}
/>
))}
<Input
id='emails'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => inputValue.trim() && addEmail(inputValue)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
: emails.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
/>
</div>
{errorMessage && <p className='mt-1 text-destructive text-xs'>{errorMessage}</p>}
</div>
{errorMessage && <p className='mt-1 text-destructive text-xs'>{errorMessage}</p>}
</div>
{/* Line separator */}
<div className='mt-6 mb-4 border-t' />
{/* Line separator */}
<div className='mt-6 mb-4 border-t' />
<PermissionsTable
userPermissions={userPermissions}
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
/>
</form>
<PermissionsTable
userPermissions={userPermissions}
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
/>
</form>
{/* Consistent spacing below user list to match spacing above */}
<div className='mb-4' />
{/* Consistent spacing below user list to match spacing above */}
<div className='mb-4' />
<AlertDialogFooter className='flex justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
Restore Changes
</Button>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
Save Changes
</Button>
</>
)}
<Button
type='submit'
disabled={!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId}
className={cn(
'ml-auto flex h-9 items-center justify-center gap-2 rounded-[8px] px-4 py-2 font-medium transition-all duration-200',
'bg-[#701FFC] text-white shadow-[0_0_0_0_#701FFC] hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(112,31,252,0.15)] disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none'
<AlertDialogFooter className='flex justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
Restore Changes
</Button>
<Button
type='button'
variant='outline'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='h-9 gap-2 rounded-[8px] font-medium'
>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
Save Changes
</Button>
</>
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{!userPerms.canAdmin ? 'Admin Access Required' : 'Send Invite'}
</Button>
</AlertDialogFooter>
<Button
type='button'
onClick={() => formRef.current?.requestSubmit()}
disabled={
!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId || !hasNewInvites
}
className={cn(
'ml-auto flex h-9 items-center justify-center gap-2 rounded-[8px] px-4 py-2 font-medium transition-all duration-200',
'bg-[#701FFC] text-white shadow-[0_0_0_0_#701FFC] hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(112,31,252,0.15)] disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none'
)}
>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{!userPerms.canAdmin ? 'Admin Access Required' : 'Send Invite'}
</Button>
</AlertDialogFooter>
</TooltipProvider>
</AlertDialogContent>
{/* Remove Member Confirmation Dialog */}