Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function AddMembersModal({
className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]'
>
<Checkbox checked={isSelected} />
<Avatar size='xs'>
<Avatar size='sm'>
{member.user?.image && (
<AvatarImage src={member.user.image} alt={name} />
)}
Expand Down Expand Up @@ -663,7 +663,7 @@ export function AccessControl() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
{member.userImage && <AvatarImage src={member.userImage} alt={name} />}
<AvatarFallback
style={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,10 @@ export function CredentialSets() {
filteredOwnedSets.length === 0 &&
!hasNoContent

// Early returns AFTER all hooks
if (membershipsLoading || invitationsLoading) {
return <CredentialSetsSkeleton />
}

// Detail view for a polling group
if (viewingSet) {
const activeMembers = members.filter((m) => m.status === 'active')
const totalCount = activeMembers.length + pendingInvitations.length
Expand Down Expand Up @@ -529,7 +527,7 @@ export function CredentialSets() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
{member.userImage && (
<AvatarImage src={member.userImage} alt={name} />
)}
Expand Down Expand Up @@ -583,7 +581,7 @@ export function CredentialSets() {
return (
<div key={invitation.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
<AvatarFallback
style={{ background: getUserColor(email) }}
className='border-0 text-white'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
'use client'

import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'

/**
* Validates a hex color string.
* Accepts 3 or 6 character hex codes with or without #.
*/
function isValidHex(hex: string): boolean {
const cleaned = hex.replace('#', '')
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleaned)
}

/**
* Normalizes a hex color to lowercase 6-character format with #.
*/
function normalizeHex(hex: string): string {
let cleaned = hex.replace('#', '').toLowerCase()
if (cleaned.length === 3) {
cleaned = cleaned
.split('')
.map((c) => c + c)
.join('')
}
return `#${cleaned}`
}

interface ContextMenuProps {
/**
Expand Down Expand Up @@ -53,6 +82,14 @@ interface ContextMenuProps {
* Callback when delete is clicked
*/
onDelete: () => void
/**
* Callback when color is changed
*/
onColorChange?: (color: string) => void
/**
* Current workflow color (for showing selected state)
*/
currentColor?: string
/**
* Whether to show the open in new tab option (default: false)
* Set to true for items that can be opened in a new tab
Expand Down Expand Up @@ -83,11 +120,21 @@ interface ContextMenuProps {
* Set to true for items that can be exported (like workspaces)
*/
showExport?: boolean
/**
* Whether to show the change color option (default: false)
* Set to true for workflows to allow color customization
*/
showColorChange?: boolean
/**
* Whether the export option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableExport?: boolean
/**
* Whether the change color option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableColorChange?: boolean
/**
* Whether the rename option is disabled (default: false)
* Set to true when user lacks permissions
Expand Down Expand Up @@ -134,23 +181,74 @@ export function ContextMenu({
onDuplicate,
onExport,
onDelete,
onColorChange,
currentColor,
showOpenInNewTab = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
showDuplicate = true,
showExport = false,
showColorChange = false,
disableExport = false,
disableColorChange = false,
disableRename = false,
disableDuplicate = false,
disableDelete = false,
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
// Section visibility for divider logic
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')

// Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
useEffect(() => {
setHexInput(currentColor || '#ffffff')
}, [currentColor])

const canSubmitHex = useMemo(() => {
if (!isValidHex(hexInput)) return false
const normalized = normalizeHex(hexInput)
if (currentColor && normalized.toLowerCase() === currentColor.toLowerCase()) return false
return true
}, [hexInput, currentColor])

const handleHexSubmit = useCallback(() => {
if (!canSubmitHex || !onColorChange) return

const normalized = normalizeHex(hexInput)
onColorChange(normalized)
setHexInput(normalized)
}, [hexInput, canSubmitHex, onColorChange])

const handleHexKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleHexSubmit()
}
},
[handleHexSubmit]
)

const handleHexChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value.trim()
if (value && !value.startsWith('#')) {
value = `#${value}`
}
value = value.slice(0, 1) + value.slice(1).replace(/[^0-9a-fA-F]/g, '')
setHexInput(value.slice(0, 7))
}, [])

const handleHexFocus = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
e.target.select()
}, [])

const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showColorChange && onColorChange)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)

return (
Expand All @@ -170,10 +268,21 @@ export function ContextMenu({
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverContent
ref={menuRef}
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* Back button - shown only when in a folder */}
<PopoverBackButton />

{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
rootOnly
onClick={() => {
onOpenInNewTab()
onClose()
Expand All @@ -182,11 +291,12 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}

{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
rootOnly
disabled={disableRename}
onClick={() => {
onRename()
Expand All @@ -198,6 +308,7 @@ export function ContextMenu({
)}
{showCreate && onCreate && (
<PopoverItem
rootOnly
disabled={disableCreate}
onClick={() => {
onCreate()
Expand All @@ -209,6 +320,7 @@ export function ContextMenu({
)}
{showCreateFolder && onCreateFolder && (
<PopoverItem
rootOnly
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
Expand All @@ -218,11 +330,72 @@ export function ContextMenu({
Create folder
</PopoverItem>
)}
{showColorChange && onColorChange && (
<PopoverFolder
id='color-picker'
title='Change color'
expandOnHover
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
>
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
{/* Preset colors */}
<div className='grid grid-cols-6 gap-[4px]'>
{WORKFLOW_COLORS.map(({ color, name }) => (
<button
key={color}
type='button'
title={name}
onClick={(e) => {
e.stopPropagation()
onColorChange(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
))}
</div>

{/* Hex input */}
<div className='flex items-center gap-[4px]'>
<div
className='h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
style={{
backgroundColor: isValidHex(hexInput) ? normalizeHex(hexInput) : '#ffffff',
}}
/>
<input
type='text'
value={hexInput}
onChange={handleHexChange}
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
/>
<button
type='button'
disabled={!canSubmitHex}
onClick={(e) => {
e.stopPropagation()
handleHexSubmit()
}}
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
>
<Check className='h-[12px] w-[12px]' />
</button>
</div>
</div>
</PopoverFolder>
)}

{/* Copy and export actions */}
{hasEditSection && hasCopySection && <PopoverDivider />}
{hasEditSection && hasCopySection && <PopoverDivider rootOnly />}
{showDuplicate && onDuplicate && (
<PopoverItem
rootOnly
disabled={disableDuplicate}
onClick={() => {
onDuplicate()
Expand All @@ -234,6 +407,7 @@ export function ContextMenu({
)}
{showExport && onExport && (
<PopoverItem
rootOnly
disabled={disableExport}
onClick={() => {
onExport()
Expand All @@ -245,8 +419,9 @@ export function ContextMenu({
)}

{/* Destructive action */}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
<PopoverItem
rootOnly
disabled={disableDelete}
onClick={() => {
onDelete()
Expand Down
Loading