mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user