mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
feat(emcn): tag input, tooltip shortcut
This commit is contained in:
@@ -28,7 +28,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
defaultTheme='dark'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import {
|
||||
type NotificationSubscription,
|
||||
@@ -156,8 +157,7 @@ export function NotificationSettings({
|
||||
errorCountThreshold: 10,
|
||||
})
|
||||
|
||||
const [emailInputValue, setEmailInputValue] = useState('')
|
||||
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>([])
|
||||
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||
|
||||
@@ -225,8 +225,7 @@ export function NotificationSettings({
|
||||
})
|
||||
setFormErrors({})
|
||||
setEditingId(null)
|
||||
setEmailInputValue('')
|
||||
setInvalidEmails([])
|
||||
setEmailItems([])
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -243,81 +242,37 @@ export function NotificationSettings({
|
||||
const normalized = email.trim().toLowerCase()
|
||||
const validation = quickValidateEmail(normalized)
|
||||
|
||||
if (formData.emailRecipients.includes(normalized) || invalidEmails.includes(normalized)) {
|
||||
if (emailItems.some((item) => item.value === normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!validation.isValid) {
|
||||
setInvalidEmails((prev) => [...prev, normalized])
|
||||
setEmailInputValue('')
|
||||
return false
|
||||
setEmailItems((prev) => [...prev, { value: normalized, isValid: validation.isValid }])
|
||||
|
||||
if (validation.isValid) {
|
||||
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
emailRecipients: [...prev.emailRecipients, normalized],
|
||||
}))
|
||||
}
|
||||
|
||||
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
emailRecipients: [...prev.emailRecipients, normalized],
|
||||
}))
|
||||
setEmailInputValue('')
|
||||
return true
|
||||
return validation.isValid
|
||||
},
|
||||
[formData.emailRecipients, invalidEmails]
|
||||
[emailItems]
|
||||
)
|
||||
|
||||
const handleRemoveEmail = useCallback((emailToRemove: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
emailRecipients: prev.emailRecipients.filter((e) => e !== emailToRemove),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleRemoveInvalidEmail = useCallback((index: number) => {
|
||||
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleEmailKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addEmail(emailInputValue)
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !emailInputValue) {
|
||||
if (invalidEmails.length > 0) {
|
||||
handleRemoveInvalidEmail(invalidEmails.length - 1)
|
||||
} else if (formData.emailRecipients.length > 0) {
|
||||
handleRemoveEmail(formData.emailRecipients[formData.emailRecipients.length - 1])
|
||||
}
|
||||
const handleRemoveEmailItem = useCallback(
|
||||
(_value: string, index: number, isValid: boolean) => {
|
||||
const itemToRemove = emailItems[index]
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (isValid && itemToRemove) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
emailRecipients: prev.emailRecipients.filter((e) => e !== itemToRemove.value),
|
||||
}))
|
||||
}
|
||||
},
|
||||
[
|
||||
emailInputValue,
|
||||
addEmail,
|
||||
invalidEmails,
|
||||
formData.emailRecipients,
|
||||
handleRemoveInvalidEmail,
|
||||
handleRemoveEmail,
|
||||
]
|
||||
)
|
||||
|
||||
const handleEmailPaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
|
||||
|
||||
let addedCount = 0
|
||||
pastedEmails.forEach((email) => {
|
||||
if (addEmail(email)) {
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedEmails.length === 1) {
|
||||
setEmailInputValue(emailInputValue + pastedEmails[0])
|
||||
}
|
||||
},
|
||||
[addEmail, emailInputValue]
|
||||
[emailItems]
|
||||
)
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
@@ -356,8 +311,11 @@ export function NotificationSettings({
|
||||
} else if (formData.emailRecipients.length > 10) {
|
||||
errors.emailRecipients = 'Maximum 10 email recipients allowed'
|
||||
}
|
||||
if (invalidEmails.length > 0) {
|
||||
errors.emailRecipients = `Invalid email addresses: ${invalidEmails.join(', ')}`
|
||||
const invalidEmailValues = emailItems
|
||||
.filter((item) => !item.isValid)
|
||||
.map((item) => item.value)
|
||||
if (invalidEmailValues.length > 0) {
|
||||
errors.emailRecipients = `Invalid email addresses: ${invalidEmailValues.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,8 +494,9 @@ export function NotificationSettings({
|
||||
inactivityHours: subscription.alertConfig?.inactivityHours || 24,
|
||||
errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10,
|
||||
})
|
||||
setEmailInputValue('')
|
||||
setInvalidEmails([])
|
||||
setEmailItems(
|
||||
(subscription.emailRecipients || []).map((email) => ({ value: email, isValid: true }))
|
||||
)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
@@ -692,37 +651,13 @@ export function NotificationSettings({
|
||||
{activeTab === 'email' && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[var(--text-secondary)]'>Email Recipients</Label>
|
||||
<div className='scrollbar-hide flex max-h-32 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-5)]'>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`invalid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => handleRemoveInvalidEmail(index)}
|
||||
isInvalid={true}
|
||||
/>
|
||||
))}
|
||||
{formData.emailRecipients.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`valid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => handleRemoveEmail(email)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type='text'
|
||||
value={emailInputValue}
|
||||
onChange={(e) => setEmailInputValue(e.target.value)}
|
||||
onKeyDown={handleEmailKeyDown}
|
||||
onPaste={handleEmailPaste}
|
||||
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
|
||||
placeholder={
|
||||
formData.emailRecipients.length > 0 || invalidEmails.length > 0
|
||||
? 'Add another email'
|
||||
: 'Enter emails'
|
||||
}
|
||||
className='min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
</div>
|
||||
<TagInput
|
||||
items={emailItems}
|
||||
onAdd={(value) => addEmail(value)}
|
||||
onRemove={handleRemoveEmailItem}
|
||||
placeholder='Enter emails'
|
||||
placeholderWithTags='Add email'
|
||||
/>
|
||||
{formErrors.emailRecipients && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
|
||||
)}
|
||||
@@ -1351,37 +1286,3 @@ export function NotificationSettings({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmailTagProps {
|
||||
email: string
|
||||
onRemove: () => void
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
function EmailTag({ email, onRemove, isInvalid }: EmailTagProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
|
||||
isInvalid
|
||||
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
|
||||
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span className='max-w-[200px] truncate'>{email}</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors focus:outline-none',
|
||||
isInvalid
|
||||
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function BlockContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Copy</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘C</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘C</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
@@ -91,7 +91,7 @@ export function BlockContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘V</span>
|
||||
</PopoverItem>
|
||||
{!hasStarterBlock && (
|
||||
<PopoverItem
|
||||
@@ -176,7 +176,7 @@ export function BlockContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Delete</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌫</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌫</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Undo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘Z</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘Z</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
@@ -72,7 +72,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Redo</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘⇧Z</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘⇧Z</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Edit and creation actions */}
|
||||
@@ -86,7 +86,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Paste</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘V</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
@@ -97,7 +97,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Add Block</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘K</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘K</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
@@ -108,7 +108,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Auto-layout</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⇧L</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* Navigation actions */}
|
||||
@@ -121,7 +121,7 @@ export function PaneContextMenu({
|
||||
}}
|
||||
>
|
||||
<span>Open Logs</span>
|
||||
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘L</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -130,14 +130,21 @@ export const Notifications = memo(function Notifications() {
|
||||
hasAction ? 'line-clamp-2' : 'line-clamp-4'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-1.5 -m-1.5 float-right ml-[16px]'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-1.5 -m-1.5 float-right ml-[16px]'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw, X } from 'lucide-react'
|
||||
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
@@ -352,6 +354,7 @@ export function ChatDeploy({
|
||||
</div>
|
||||
|
||||
<AuthSelector
|
||||
key={existingChat?.id ?? 'new'}
|
||||
authType={formData.authType}
|
||||
password={formData.password}
|
||||
emails={formData.emails}
|
||||
@@ -583,10 +586,11 @@ function AuthSelector({
|
||||
error,
|
||||
}: AuthSelectorProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [emailInputValue, setEmailInputValue] = useState('')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>(() =>
|
||||
emails.map((email) => ({ value: email, isValid: true }))
|
||||
)
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const newPassword = generatePassword(24)
|
||||
@@ -607,59 +611,25 @@ function AuthSelector({
|
||||
const validation = quickValidateEmail(normalized)
|
||||
const isValid = validation.isValid || isDomainPattern
|
||||
|
||||
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
|
||||
if (emailItems.some((item) => item.value === normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setInvalidEmails((prev) => [...prev, normalized])
|
||||
setEmailInputValue('')
|
||||
return false
|
||||
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
|
||||
|
||||
if (isValid) {
|
||||
setEmailError('')
|
||||
onEmailsChange([...emails, normalized])
|
||||
}
|
||||
|
||||
setEmailError('')
|
||||
onEmailsChange([...emails, normalized])
|
||||
setEmailInputValue('')
|
||||
return true
|
||||
return isValid
|
||||
}
|
||||
|
||||
const handleRemoveEmail = (emailToRemove: string) => {
|
||||
onEmailsChange(emails.filter((e) => e !== emailToRemove))
|
||||
}
|
||||
|
||||
const handleRemoveInvalidEmail = (index: number) => {
|
||||
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addEmail(emailInputValue)
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !emailInputValue) {
|
||||
if (invalidEmails.length > 0) {
|
||||
handleRemoveInvalidEmail(invalidEmails.length - 1)
|
||||
} else if (emails.length > 0) {
|
||||
handleRemoveEmail(emails[emails.length - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
|
||||
|
||||
let addedCount = 0
|
||||
pastedEmails.forEach((email) => {
|
||||
if (addEmail(email)) {
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedEmails.length === 1) {
|
||||
setEmailInputValue(emailInputValue + pastedEmails[0])
|
||||
const handleRemoveEmailItem = (_value: string, index: number, isValid: boolean) => {
|
||||
const itemToRemove = emailItems[index]
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (isValid && itemToRemove) {
|
||||
onEmailsChange(emails.filter((e) => e !== itemToRemove.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,40 +744,14 @@ function AuthSelector({
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
|
||||
</Label>
|
||||
<div className='scrollbar-hide flex max-h-32 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-5)]'>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`invalid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => handleRemoveInvalidEmail(index)}
|
||||
disabled={disabled}
|
||||
isInvalid={true}
|
||||
/>
|
||||
))}
|
||||
{emails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`valid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => handleRemoveEmail(email)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type='text'
|
||||
value={emailInputValue}
|
||||
onChange={(e) => setEmailInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
|
||||
placeholder={
|
||||
emails.length > 0 || invalidEmails.length > 0
|
||||
? 'Add another email'
|
||||
: 'Enter emails or domains (@example.com)'
|
||||
}
|
||||
className='min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<TagInput
|
||||
items={emailItems}
|
||||
onAdd={(value) => addEmail(value)}
|
||||
onRemove={handleRemoveEmailItem}
|
||||
placeholder='Enter emails or domains (@example.com)'
|
||||
placeholderWithTags='Add email'
|
||||
disabled={disabled}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
|
||||
)}
|
||||
@@ -823,40 +767,3 @@ function AuthSelector({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmailTagProps {
|
||||
email: string
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
function EmailTag({ email, onRemove, disabled, isInvalid }: EmailTagProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
|
||||
isInvalid
|
||||
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
|
||||
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span className='max-w-[200px] truncate'>{email}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors focus:outline-none',
|
||||
isInvalid
|
||||
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Loader2, X } from 'lucide-react'
|
||||
import { Badge, ButtonGroup, ButtonGroupItem, Input, Label, Textarea } from '@/components/emcn'
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Input,
|
||||
Label,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
@@ -85,7 +94,7 @@ export function FormDeploy({
|
||||
)
|
||||
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
|
||||
const [password, setPassword] = useState('')
|
||||
const [allowedEmails, setAllowedEmails] = useState<string[]>([])
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>([])
|
||||
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [formUrl, setFormUrl] = useState('')
|
||||
@@ -143,7 +152,9 @@ export function FormDeploy({
|
||||
'Your response has been submitted successfully.'
|
||||
)
|
||||
setAuthType(form.authType)
|
||||
setAllowedEmails(form.allowedEmails || [])
|
||||
setEmailItems(
|
||||
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
|
||||
)
|
||||
if (form.customizations?.fieldConfigs) {
|
||||
setFieldConfigs(form.customizations.fieldConfigs)
|
||||
}
|
||||
@@ -210,6 +221,8 @@ export function FormDeploy({
|
||||
}
|
||||
}, [workflowId, fieldConfigs.length])
|
||||
|
||||
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
|
||||
|
||||
// Validate form
|
||||
useEffect(() => {
|
||||
const isValid =
|
||||
@@ -225,7 +238,7 @@ export function FormDeploy({
|
||||
title,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
allowedEmails.length,
|
||||
existingForm?.hasPassword,
|
||||
onValidationChange,
|
||||
inputFields.length,
|
||||
@@ -612,41 +625,27 @@ export function FormDeploy({
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Allowed emails
|
||||
</Label>
|
||||
<div className='flex flex-wrap items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px]'>
|
||||
{allowedEmails.map((email) => (
|
||||
<div
|
||||
key={email}
|
||||
className='flex items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[2px] text-[12px]'
|
||||
>
|
||||
<span>{email}</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setAllowedEmails(allowedEmails.filter((e) => e !== email))}
|
||||
className='text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
type='text'
|
||||
placeholder={
|
||||
allowedEmails.length > 0 ? 'Add another' : 'Enter emails or @domain.com'
|
||||
<TagInput
|
||||
items={emailItems}
|
||||
onAdd={(value) => {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (!trimmed || emailItems.some((item) => item.value === trimmed)) {
|
||||
return false
|
||||
}
|
||||
className='min-w-[150px] flex-1 border-none bg-transparent p-0 text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
const value = (e.target as HTMLInputElement).value.trim()
|
||||
if (value && !allowedEmails.includes(value)) {
|
||||
setAllowedEmails([...allowedEmails, value])
|
||||
clearError('emails')
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
const isDomainPattern = trimmed.startsWith('@')
|
||||
const isValid = isDomainPattern || trimmed.includes('@')
|
||||
setEmailItems((prev) => [...prev, { value: trimmed, isValid }])
|
||||
if (isValid) {
|
||||
clearError('emails')
|
||||
}
|
||||
return isValid
|
||||
}}
|
||||
onRemove={(_value, index) => {
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
}}
|
||||
placeholder='Enter emails or @domain.com'
|
||||
placeholderWithTags='Add another'
|
||||
/>
|
||||
{errors.emails && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.emails}</p>
|
||||
)}
|
||||
|
||||
@@ -225,7 +225,7 @@ export function DocumentTagEntry({
|
||||
*/
|
||||
const renderTagHeader = (tag: DocumentTag, index: number) => (
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapse(tag.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
|
||||
@@ -373,7 +373,7 @@ function InputMappingField({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function KnowledgeTagFilters({
|
||||
*/
|
||||
const renderFilterHeader = (filter: TagFilter, index: number) => (
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapse(filter.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function FieldFormat({
|
||||
*/
|
||||
const renderFieldHeader = (field: Field, index: number) => (
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapse(field.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
|
||||
@@ -2156,7 +2156,7 @@ export function ToolInput({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
|
||||
'flex items-center justify-between gap-[8px] rounded-t-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
|
||||
(isCustomTool || hasToolBody) && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@@ -318,7 +318,7 @@ export function VariablesInput({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapse(assignment.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { ArrowUp, Square } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
BubbleChatClose,
|
||||
BubbleChatPreview,
|
||||
Button,
|
||||
Copy,
|
||||
@@ -427,7 +428,7 @@ export function Panel() {
|
||||
variant={isChatOpen ? 'active' : 'default'}
|
||||
onClick={() => setIsChatOpen(!isChatOpen)}
|
||||
>
|
||||
<BubbleChatPreview />
|
||||
{isChatOpen ? <BubbleChatClose /> : <BubbleChatPreview />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1240,7 +1240,7 @@ export function Terminal() {
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Clear console</span>
|
||||
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
@@ -1605,7 +1605,7 @@ export function Terminal() {
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Clear console</span>
|
||||
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface EmailTagProps {
|
||||
email: string
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
isInvalid?: boolean
|
||||
isSent?: boolean
|
||||
}
|
||||
|
||||
export const EmailTag = React.memo<EmailTagProps>(
|
||||
({ email, onRemove, disabled, isInvalid, isSent }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
|
||||
isInvalid
|
||||
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
|
||||
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span className='max-w-[200px] truncate'>{email}</span>
|
||||
{isSent && <span className='text-[11px] text-[var(--text-tertiary)]'>sent</span>}
|
||||
{!disabled && !isSent && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors focus:outline-none',
|
||||
isInvalid
|
||||
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
EmailTag.displayName = 'EmailTag'
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './email-tag'
|
||||
export * from './permission-selector'
|
||||
export * from './permissions-table'
|
||||
export * from './permissions-table-skeleton'
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
import type { PermissionType, UserPermissions } from './components'
|
||||
import { EmailTag, PermissionsTable } from './components'
|
||||
import { PermissionsTable } from './components'
|
||||
|
||||
const logger = createLogger('InviteModal')
|
||||
|
||||
@@ -40,9 +40,7 @@ interface PendingInvitation {
|
||||
|
||||
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>([])
|
||||
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
|
||||
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
|
||||
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
|
||||
@@ -79,7 +77,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
} = useWorkspacePermissionsContext()
|
||||
|
||||
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
|
||||
const hasNewInvites = emails.length > 0 || inputValue.trim()
|
||||
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
|
||||
const hasNewInvites = validEmails.length > 0
|
||||
|
||||
const fetchPendingInvitations = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
@@ -134,14 +133,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
const validation = quickValidateEmail(normalized)
|
||||
const isValid = validation.isValid
|
||||
|
||||
if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
|
||||
if (emailItems.some((item) => item.value === normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized)
|
||||
if (hasPendingInvitation) {
|
||||
setErrorMessage(`${normalized} already has a pending invitation`)
|
||||
setInputValue('')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -150,52 +148,43 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
)
|
||||
if (isExistingMember) {
|
||||
setErrorMessage(`${normalized} is already a member of this workspace`)
|
||||
setInputValue('')
|
||||
return false
|
||||
}
|
||||
|
||||
if (session?.user?.email && session.user.email.toLowerCase() === normalized) {
|
||||
setErrorMessage('You cannot invite yourself')
|
||||
setInputValue('')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setInvalidEmails((prev) => [...prev, normalized])
|
||||
setInputValue('')
|
||||
return false
|
||||
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
|
||||
|
||||
if (isValid) {
|
||||
setErrorMessage(null)
|
||||
setUserPermissions((prev) => [
|
||||
...prev,
|
||||
{
|
||||
email: normalized,
|
||||
permissionType: 'read',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
setEmails((prev) => [...prev, normalized])
|
||||
|
||||
setUserPermissions((prev) => [
|
||||
...prev,
|
||||
{
|
||||
email: normalized,
|
||||
permissionType: 'read',
|
||||
},
|
||||
])
|
||||
|
||||
setInputValue('')
|
||||
return true
|
||||
return isValid
|
||||
},
|
||||
[emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email]
|
||||
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
|
||||
)
|
||||
|
||||
const removeEmail = useCallback(
|
||||
(index: number) => {
|
||||
const emailToRemove = emails[index]
|
||||
setEmails((prev) => prev.filter((_, i) => i !== index))
|
||||
setUserPermissions((prev) => prev.filter((user) => user.email !== emailToRemove))
|
||||
const removeEmailItem = useCallback(
|
||||
(_value: string, index: number, isValid?: boolean) => {
|
||||
const itemToRemove = emailItems[index]
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (isValid ?? itemToRemove?.isValid) {
|
||||
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
|
||||
}
|
||||
},
|
||||
[emails]
|
||||
[emailItems]
|
||||
)
|
||||
|
||||
const removeInvalidEmail = useCallback((index: number) => {
|
||||
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handlePermissionChange = useCallback(
|
||||
(identifier: string, permissionType: PermissionType) => {
|
||||
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
|
||||
@@ -472,57 +461,15 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
[workspaceId, userPerms.canAdmin, resendCooldowns]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addEmail(inputValue)
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
if (invalidEmails.length > 0) {
|
||||
removeInvalidEmail(invalidEmails.length - 1)
|
||||
} else if (emails.length > 0) {
|
||||
removeEmail(emails.length - 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
[inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
|
||||
|
||||
let addedCount = 0
|
||||
pastedEmails.forEach((email) => {
|
||||
if (addEmail(email)) {
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedEmails.length === 1) {
|
||||
setInputValue(inputValue + pastedEmails[0])
|
||||
}
|
||||
},
|
||||
[addEmail, inputValue]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (inputValue.trim()) {
|
||||
addEmail(inputValue)
|
||||
}
|
||||
|
||||
// Clear messages at start of submission
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
if (emails.length === 0 || !workspaceId) {
|
||||
if (validEmails.length === 0 || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -532,7 +479,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
const failedInvites: string[] = []
|
||||
|
||||
const results = await Promise.all(
|
||||
emails.map(async (email) => {
|
||||
validEmails.map(async (email) => {
|
||||
try {
|
||||
const userPermission = userPermissions.find((up) => up.email === email)
|
||||
const permissionType = userPermission?.permissionType || 'read'
|
||||
@@ -553,9 +500,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!invalidEmails.includes(email)) {
|
||||
failedInvites.push(email)
|
||||
}
|
||||
failedInvites.push(email)
|
||||
|
||||
if (data.error) {
|
||||
setErrorMessage(data.error)
|
||||
@@ -566,16 +511,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
|
||||
return true
|
||||
} catch {
|
||||
if (!invalidEmails.includes(email)) {
|
||||
failedInvites.push(email)
|
||||
}
|
||||
failedInvites.push(email)
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = results.filter(Boolean).length
|
||||
const successfulEmails = emails.filter((_, index) => results[index])
|
||||
const successfulEmails = validEmails.filter((_, index) => results[index])
|
||||
|
||||
if (successCount > 0) {
|
||||
if (successfulEmails.length > 0) {
|
||||
@@ -605,17 +548,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
}
|
||||
|
||||
fetchPendingInvitations()
|
||||
setInputValue('')
|
||||
|
||||
if (failedInvites.length > 0) {
|
||||
setEmails(failedInvites)
|
||||
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
|
||||
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
|
||||
} else {
|
||||
setEmails([])
|
||||
setEmailItems([])
|
||||
setUserPermissions([])
|
||||
}
|
||||
|
||||
setInvalidEmails([])
|
||||
setShowSent(true)
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -631,23 +571,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
inputValue,
|
||||
addEmail,
|
||||
emails,
|
||||
workspaceId,
|
||||
userPermissions,
|
||||
invalidEmails,
|
||||
fetchPendingInvitations,
|
||||
onOpenChange,
|
||||
]
|
||||
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
|
||||
)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
// Batch state updates using React's automatic batching in React 18+
|
||||
setInputValue('')
|
||||
setEmails([])
|
||||
setInvalidEmails([])
|
||||
setEmailItems([])
|
||||
setUserPermissions([])
|
||||
setPendingInvitations([])
|
||||
setIsPendingInvitationsLoading(false)
|
||||
@@ -718,55 +647,21 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[4px] focus-within:outline-none'>
|
||||
{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='invite-field'
|
||||
name='invite_search_field'
|
||||
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 bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
|
||||
)}
|
||||
autoFocus={userPerms.canAdmin}
|
||||
disabled={isSubmitting || !userPerms.canAdmin}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
</div>
|
||||
<TagInput
|
||||
id='invite-field'
|
||||
name='invite_search_field'
|
||||
items={emailItems}
|
||||
onAdd={(value) => addEmail(value)}
|
||||
onRemove={removeEmailItem}
|
||||
placeholder={
|
||||
!userPerms.canAdmin
|
||||
? 'Only administrators can invite new members'
|
||||
: 'Enter emails'
|
||||
}
|
||||
placeholderWithTags='Add email'
|
||||
autoFocus={userPerms.canAdmin}
|
||||
disabled={isSubmitting || !userPerms.canAdmin}
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>{errorMessage}</p>
|
||||
|
||||
@@ -107,6 +107,15 @@ export {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './table/table'
|
||||
export {
|
||||
Tag,
|
||||
TagInput,
|
||||
type TagInputProps,
|
||||
type TagItem,
|
||||
type TagProps,
|
||||
tagInputVariants,
|
||||
tagVariants,
|
||||
} from './tag-input/tag-input'
|
||||
export { Textarea } from './textarea/textarea'
|
||||
export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker'
|
||||
export { Tooltip } from './tooltip/tooltip'
|
||||
|
||||
340
apps/sim/components/emcn/components/tag-input/tag-input.tsx
Normal file
340
apps/sim/components/emcn/components/tag-input/tag-input.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* A tag input component for managing a list of tags with validation support.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { TagInput, type TagItem } from '@/components/emcn'
|
||||
*
|
||||
* const [items, setItems] = useState<TagItem[]>([])
|
||||
*
|
||||
* <TagInput
|
||||
* items={items}
|
||||
* onAdd={(value) => {
|
||||
* const isValid = isValidEmail(value)
|
||||
* setItems(prev => [...prev, { value, isValid }])
|
||||
* return isValid
|
||||
* }}
|
||||
* onRemove={(value, index) => {
|
||||
* setItems(prev => prev.filter((_, i) => i !== index))
|
||||
* }}
|
||||
* placeholder="Enter emails"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
* Variant styles for the Tag component.
|
||||
* No vertical padding to fit within the input's natural line height without causing expansion.
|
||||
* Uses colored badge-style variants (blue for valid, red for invalid).
|
||||
*/
|
||||
const tagVariants = cva(
|
||||
'flex w-auto cursor-default items-center gap-[3px] rounded-[4px] px-[4px] font-medium font-sans text-[13px] leading-[20px] transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[#bfdbfe] text-[#1d4ed8] dark:bg-[rgba(59,130,246,0.2)] dark:text-[#93c5fd]',
|
||||
invalid:
|
||||
'bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Props for the Tag component.
|
||||
*/
|
||||
export interface TagProps extends VariantProps<typeof tagVariants> {
|
||||
/** The tag value to display */
|
||||
value: string
|
||||
/** Callback when remove button is clicked */
|
||||
onRemove?: () => void
|
||||
/** Whether the tag is disabled */
|
||||
disabled?: boolean
|
||||
/** Additional class names */
|
||||
className?: string
|
||||
/** Optional suffix content (e.g., "sent" label) */
|
||||
suffix?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A single tag badge with optional remove button.
|
||||
*/
|
||||
const Tag = React.memo(function Tag({
|
||||
value,
|
||||
onRemove,
|
||||
disabled,
|
||||
variant,
|
||||
className,
|
||||
suffix,
|
||||
}: TagProps) {
|
||||
return (
|
||||
<div className={cn(tagVariants({ variant }), className)}>
|
||||
<span className='max-w-[200px] truncate'>{value}</span>
|
||||
{suffix}
|
||||
{!disabled && onRemove && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 opacity-80 transition-opacity hover:opacity-100 focus:outline-none',
|
||||
variant === 'invalid'
|
||||
? 'text-[var(--text-error)]'
|
||||
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
|
||||
)}
|
||||
aria-label={`Remove ${value}`}
|
||||
>
|
||||
<X className='h-[12px] w-[12px] translate-y-[0.5px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Variant styles for the TagInput container.
|
||||
* Matches the Input component styling exactly for consistent height.
|
||||
*/
|
||||
const tagInputVariants = cva(
|
||||
'scrollbar-hide flex w-full cursor-text flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-colors focus-within:outline-none dark:bg-[var(--surface-5)]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a tag item with its value and validity status.
|
||||
*/
|
||||
export interface TagItem {
|
||||
value: string
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the TagInput component.
|
||||
*/
|
||||
export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
|
||||
/** Array of tag items with value and validity status */
|
||||
items: TagItem[]
|
||||
/**
|
||||
* Callback when a new tag is added.
|
||||
* Return true if the value was valid and added, false if invalid.
|
||||
*/
|
||||
onAdd: (value: string) => boolean
|
||||
/** Callback when a tag is removed (receives value, index, and isValid) */
|
||||
onRemove: (value: string, index: number, isValid: boolean) => void
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string
|
||||
/** Placeholder text when there are existing tags */
|
||||
placeholderWithTags?: string
|
||||
/** Whether the input is disabled */
|
||||
disabled?: boolean
|
||||
/** Additional class names for the container */
|
||||
className?: string
|
||||
/** Additional class names for the input */
|
||||
inputClassName?: string
|
||||
/** Maximum height for the container (defaults to 128px / max-h-32) */
|
||||
maxHeight?: string
|
||||
/** HTML id for the input element */
|
||||
id?: string
|
||||
/** HTML name for the input element */
|
||||
name?: string
|
||||
/** Whether to auto-focus the input */
|
||||
autoFocus?: boolean
|
||||
/** Custom keys that trigger tag addition (defaults to Enter, comma, space) */
|
||||
triggerKeys?: string[]
|
||||
/** Optional render function for tag suffix content */
|
||||
renderTagSuffix?: (value: string, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* An input component for managing a list of tags.
|
||||
*
|
||||
* @remarks
|
||||
* - Maintains consistent height with standard Input component
|
||||
* - Supports keyboard navigation (Enter/comma/space to add, Backspace to remove)
|
||||
* - Handles paste with multiple values separated by whitespace, commas, or semicolons
|
||||
* - Displays invalid values with error styling
|
||||
*/
|
||||
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
(
|
||||
{
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
placeholder = 'Enter values',
|
||||
placeholderWithTags = 'Add another',
|
||||
disabled = false,
|
||||
className,
|
||||
inputClassName,
|
||||
maxHeight = 'max-h-32',
|
||||
id,
|
||||
name,
|
||||
autoFocus = false,
|
||||
triggerKeys = ['Enter', ',', ' '],
|
||||
renderTagSuffix,
|
||||
variant,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
const internalRef = React.useRef<HTMLInputElement>(null)
|
||||
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
|
||||
|
||||
const hasItems = items.length > 0
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [autoFocus, inputRef])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (triggerKeys.includes(e.key) && inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
onAdd(inputValue.trim())
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !inputValue && items.length > 0) {
|
||||
const lastItem = items[items.length - 1]
|
||||
onRemove(lastItem.value, items.length - 1, lastItem.isValid)
|
||||
}
|
||||
},
|
||||
[inputValue, triggerKeys, onAdd, items, onRemove]
|
||||
)
|
||||
|
||||
const handlePaste = React.useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const pastedValues = pastedText.split(/[\s,;]+/).filter(Boolean)
|
||||
|
||||
let addedCount = 0
|
||||
pastedValues.forEach((value) => {
|
||||
if (onAdd(value.trim())) {
|
||||
addedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedValues.length === 1) {
|
||||
setInputValue(inputValue + pastedValues[0])
|
||||
}
|
||||
},
|
||||
[onAdd, inputValue]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
if (inputValue.trim()) {
|
||||
onAdd(inputValue.trim())
|
||||
setInputValue('')
|
||||
}
|
||||
}, [inputValue, onAdd])
|
||||
|
||||
const handleContainerClick = React.useCallback(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(tagInputVariants({ variant }), maxHeight, className)}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Tag
|
||||
key={`item-${index}`}
|
||||
value={item.value}
|
||||
variant={item.isValid ? 'default' : 'invalid'}
|
||||
onRemove={() => onRemove(item.value, index, item.isValid)}
|
||||
disabled={disabled}
|
||||
suffix={item.isValid ? renderTagSuffix?.(item.value, index) : undefined}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
inputValue.trim() &&
|
||||
cn(tagVariants({ variant: 'default' }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
|
||||
)}
|
||||
>
|
||||
<div className='relative inline-flex'>
|
||||
{inputValue.trim() && (
|
||||
<span
|
||||
className='invisible whitespace-pre font-medium font-sans text-[13px] leading-[20px]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{inputValue}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
placeholder={hasItems ? placeholderWithTags : placeholder}
|
||||
size={hasItems ? placeholderWithTags?.length || 10 : placeholder?.length || 12}
|
||||
className={cn(
|
||||
'border-none bg-transparent font-medium font-sans outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
inputValue.trim()
|
||||
? 'absolute top-0 left-0 h-full w-full p-0 text-[13px] text-inherit leading-[20px]'
|
||||
: 'w-auto min-w-0 p-0 text-foreground text-sm',
|
||||
inputClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
</div>
|
||||
{inputValue.trim() && (
|
||||
<button
|
||||
type='button'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (inputValue.trim()) {
|
||||
onAdd(inputValue.trim())
|
||||
setInputValue('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
className='flex items-center px-[3px] opacity-80 transition-opacity hover:opacity-100 focus:outline-none'
|
||||
disabled={disabled}
|
||||
aria-label='Add tag'
|
||||
>
|
||||
<Plus className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TagInput.displayName = 'TagInput'
|
||||
|
||||
export { Tag, TagInput, tagInputVariants, tagVariants }
|
||||
@@ -57,9 +57,37 @@ const Content = React.forwardRef<
|
||||
))
|
||||
Content.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
interface ShortcutProps {
|
||||
/** The keyboard shortcut keys to display (e.g., "⌘D", "⌘K") */
|
||||
keys: string
|
||||
/** Optional additional class names */
|
||||
className?: string
|
||||
/** Optional children to display before the shortcut */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a keyboard shortcut within tooltip content.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tooltip.Content>
|
||||
* <Tooltip.Shortcut keys="⌘D">Clear console</Tooltip.Shortcut>
|
||||
* </Tooltip.Content>
|
||||
* ```
|
||||
*/
|
||||
const Shortcut = ({ keys, className, children }: ShortcutProps) => (
|
||||
<span className={cn('flex items-center gap-[8px]', className)}>
|
||||
{children && <span>{children}</span>}
|
||||
<span className='opacity-70'>{keys}</span>
|
||||
</span>
|
||||
)
|
||||
Shortcut.displayName = 'Tooltip.Shortcut'
|
||||
|
||||
export const Tooltip = {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
Provider,
|
||||
Shortcut,
|
||||
}
|
||||
|
||||
28
apps/sim/components/emcn/icons/bubble-chat-close.tsx
Normal file
28
apps/sim/components/emcn/icons/bubble-chat-close.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* BubbleChatClose icon component - chat bubble with solid eye to indicate closing
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function BubbleChatClose(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 14 14'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M10.208 0.729126C9.46715 0.729126 8.8405 1.03967 8.3672 1.39158C7.89351 1.74377 7.54598 2.15728 7.35252 2.41564C7.28999 2.49672 7.14551 2.68425 7.14551 2.91663C7.14551 3.149 7.28999 3.33653 7.35252 3.41761C7.54598 3.67598 7.89351 4.08948 8.3672 4.44168C8.8405 4.79358 9.46715 5.10413 10.208 5.10413C10.9489 5.10413 11.5755 4.79358 12.0488 4.44168C12.5225 4.08948 12.87 3.67597 13.0635 3.41761C13.126 3.33653 13.2705 3.149 13.2705 2.91663C13.2705 2.68425 13.126 2.49672 13.0635 2.41564C12.87 2.15728 12.5225 1.74377 12.0488 1.39158C11.5755 1.03967 10.9489 0.729126 10.208 0.729126Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<circle cx='10.208' cy='2.91663' r='0.875' fill='currentColor' />
|
||||
<path
|
||||
d='M7 0.729431C7.25201 0.729431 7.37829 0.728973 7.43066 0.789978C7.45461 0.817872 7.46841 0.85201 7.47168 0.888611C7.47864 0.968713 7.38194 1.06792 7.18848 1.26556C6.95871 1.50031 6.7804 1.72067 6.65723 1.8847L6.65137 1.89154C6.58026 1.98206 6.27051 2.37696 6.27051 2.91693C6.27061 3.45637 6.57996 3.85045 6.65137 3.94135L6.65723 3.94818C6.88003 4.24495 7.28385 4.72574 7.8457 5.14349C8.41007 5.56311 9.21632 5.97934 10.208 5.97943C11.1998 5.97943 12.0069 5.56316 12.5713 5.14349C12.7408 5.01748 12.8259 4.95516 12.9023 4.97064C12.9183 4.9739 12.9338 4.97875 12.9482 4.98627C13.0174 5.02234 13.0412 5.1157 13.0889 5.3017C13.2075 5.76414 13.2705 6.24824 13.2705 6.74701C13.2705 10.0885 10.4444 12.7656 7 12.7656C6.59416 12.766 6.18957 12.7281 5.79102 12.6533C5.65266 12.6273 5.56443 12.6115 5.49902 12.6025C5.45024 12.595 5.40122 12.613 5.38281 12.623C5.31602 12.6547 5.22698 12.7019 5.09082 12.7744C4.25576 13.2184 3.28146 13.3758 2.3418 13.2011C2.19004 13.1729 2.06398 13.0667 2.01074 12.9218C1.95752 12.777 1.98471 12.6148 2.08203 12.4951C2.35492 12.1594 2.543 11.7545 2.62598 11.3193C2.64818 11.1997 2.59719 11.0372 2.44141 10.8788C1.38277 9.80387 0.729492 8.34981 0.729492 6.74701C0.729632 3.4056 3.55564 0.729431 7 0.729431ZM4.66699 6.41693C4.34485 6.41693 4.08305 6.67781 4.08301 6.99994C4.08301 7.32211 4.34483 7.58295 4.66699 7.58295H4.67188C4.99404 7.58295 5.25488 7.32211 5.25488 6.99994C5.25484 6.67781 4.99401 6.41693 4.67188 6.41693H4.66699ZM6.99707 6.41693C6.67508 6.4171 6.41411 6.67792 6.41406 6.99994C6.41406 7.322 6.67505 7.58278 6.99707 7.58295H7.00293C7.32495 7.58278 7.58594 7.322 7.58594 6.99994C7.58589 6.67792 7.32492 6.4171 7.00293 6.41693H6.99707ZM9.33105 6.41693C9.00892 6.41693 8.74712 6.67781 8.74707 6.99994C8.74707 7.32211 9.00889 7.58295 9.33105 7.58295H9.33594C9.6581 7.58295 9.91895 7.32211 9.91895 6.99994C9.9189 6.67781 9.65808 6.41693 9.33594 6.41693H9.33105Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BubbleChatClose } from './bubble-chat-close'
|
||||
export { BubbleChatPreview } from './bubble-chat-preview'
|
||||
export { Card } from './card'
|
||||
export { ChevronDown } from './chevron-down'
|
||||
|
||||
@@ -43,7 +43,7 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
|
||||
autoConnect: data.autoConnect ?? true,
|
||||
showTrainingControls: data.showTrainingControls ?? false,
|
||||
superUserModeEnabled: data.superUserModeEnabled ?? true,
|
||||
theme: data.theme || 'system',
|
||||
theme: data.theme || 'dark',
|
||||
telemetryEnabled: data.telemetryEnabled ?? true,
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
|
||||
|
||||
@@ -38,6 +38,6 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
|
||||
* Gets the current theme from next-themes localStorage
|
||||
*/
|
||||
export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'system'
|
||||
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system'
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'dark'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user