mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(chat-deploy): added new image upload component, fixed some state issues with success view (#842)
* fix(chat-deploy): added new image upload component, fixed some state issues with success view * cleanup --------- Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
This commit is contained in:
@@ -1,18 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
ImageUpload,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
|
||||
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
|
||||
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
|
||||
@@ -32,6 +42,9 @@ interface ChatDeployProps {
|
||||
setChatSubmitting: (submitting: boolean) => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onPreDeployWorkflow?: () => Promise<void>
|
||||
showDeleteConfirmation?: boolean
|
||||
setShowDeleteConfirmation?: (show: boolean) => void
|
||||
onDeploymentComplete?: () => void
|
||||
}
|
||||
|
||||
interface ExistingChat {
|
||||
@@ -56,9 +69,27 @@ export function ChatDeploy({
|
||||
setChatSubmitting,
|
||||
onValidationChange,
|
||||
onPreDeployWorkflow,
|
||||
showDeleteConfirmation: externalShowDeleteConfirmation,
|
||||
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
|
||||
onDeploymentComplete,
|
||||
}: ChatDeployProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isImageUploading, setIsImageUploading] = useState(false)
|
||||
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
|
||||
const [showSuccessView, setShowSuccessView] = useState(false)
|
||||
|
||||
// Use external state for delete confirmation if provided
|
||||
const showDeleteConfirmation =
|
||||
externalShowDeleteConfirmation !== undefined
|
||||
? externalShowDeleteConfirmation
|
||||
: internalShowDeleteConfirmation
|
||||
|
||||
const setShowDeleteConfirmation =
|
||||
externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
|
||||
|
||||
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
|
||||
const { deployedUrl, deployChat } = useChatDeployment()
|
||||
@@ -115,10 +146,18 @@ export function ChatDeploy({
|
||||
: [],
|
||||
})
|
||||
|
||||
// Set image URL if it exists
|
||||
if (chatDetail.customizations?.imageUrl) {
|
||||
setImageUrl(chatDetail.customizations.imageUrl)
|
||||
}
|
||||
setImageUploadError(null)
|
||||
|
||||
onChatExistsChange?.(true)
|
||||
}
|
||||
} else {
|
||||
setExistingChat(null)
|
||||
setImageUrl(null)
|
||||
setImageUploadError(null)
|
||||
onChatExistsChange?.(false)
|
||||
}
|
||||
}
|
||||
@@ -150,9 +189,14 @@ export function ChatDeploy({
|
||||
return
|
||||
}
|
||||
|
||||
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id)
|
||||
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl)
|
||||
|
||||
onChatExistsChange?.(true)
|
||||
setShowSuccessView(true)
|
||||
|
||||
// Fetch the updated chat data immediately after deployment
|
||||
// This ensures existingChat is available when switching back to edit mode
|
||||
await fetchExistingChat()
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('subdomain')) {
|
||||
setError('subdomain', error.message)
|
||||
@@ -164,111 +208,263 @@ export function ChatDeploy({
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingChat || !existingChat.id) return
|
||||
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(`/api/chat/edit/${existingChat.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete chat')
|
||||
}
|
||||
|
||||
// Update state
|
||||
setExistingChat(null)
|
||||
setImageUrl(null)
|
||||
setImageUploadError(null)
|
||||
onChatExistsChange?.(false)
|
||||
|
||||
// Notify parent of successful deletion
|
||||
onDeploymentComplete?.()
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
setError('general', error.message || 'An unexpected error occurred while deleting')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteConfirmation(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (deployedUrl) {
|
||||
return <SuccessView deployedUrl={deployedUrl} existingChat={existingChat} />
|
||||
if (deployedUrl && showSuccessView) {
|
||||
return (
|
||||
<>
|
||||
<div id='chat-deploy-form'>
|
||||
<SuccessView
|
||||
deployedUrl={deployedUrl}
|
||||
existingChat={existingChat}
|
||||
onDelete={() => setShowDeleteConfirmation(true)}
|
||||
onUpdate={() => setShowSuccessView(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat deployment at{' '}
|
||||
<span className='font-mono text-destructive'>
|
||||
{existingChat?.subdomain}.{getEmailDomain()}
|
||||
</span>
|
||||
.
|
||||
<span className='mt-2 block'>
|
||||
All users will lose access immediately, and this action cannot be undone.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
{isDeleting ? (
|
||||
<span className='flex items-center'>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Deleting...
|
||||
</span>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id='chat-deploy-form'
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className='-mx-1 space-y-4 overflow-y-auto px-1'
|
||||
>
|
||||
{errors.general && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>{errors.general}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<>
|
||||
<form
|
||||
id='chat-deploy-form'
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className='-mx-1 space-y-4 overflow-y-auto px-1'
|
||||
>
|
||||
{errors.general && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>{errors.general}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='space-y-4'>
|
||||
<SubdomainInput
|
||||
value={formData.subdomain}
|
||||
onChange={(value) => updateField('subdomain', value)}
|
||||
originalSubdomain={existingChat?.subdomain}
|
||||
disabled={chatSubmitting}
|
||||
onValidationChange={setIsSubdomainValid}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='title' className='font-medium text-sm'>
|
||||
Chat Title
|
||||
</Label>
|
||||
<Input
|
||||
id='title'
|
||||
placeholder='Customer Support Assistant'
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
required
|
||||
<div className='space-y-4'>
|
||||
<SubdomainInput
|
||||
value={formData.subdomain}
|
||||
onChange={(value) => updateField('subdomain', value)}
|
||||
originalSubdomain={existingChat?.subdomain || undefined}
|
||||
disabled={chatSubmitting}
|
||||
onValidationChange={setIsSubdomainValid}
|
||||
isEditingExisting={!!existingChat}
|
||||
/>
|
||||
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='description' className='font-medium text-sm'>
|
||||
Description (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
placeholder='A brief description of what this chat does'
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
rows={3}
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Chat Output</Label>
|
||||
<Card className='rounded-md border-input shadow-none'>
|
||||
<CardContent className='p-1'>
|
||||
<OutputSelect
|
||||
workflowId={workflowId}
|
||||
selectedOutputs={formData.selectedOutputBlocks}
|
||||
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
|
||||
placeholder='Select which block outputs to use'
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{errors.outputBlocks && <p className='text-destructive text-sm'>{errors.outputBlocks}</p>}
|
||||
<p className='mt-2 text-muted-foreground text-xs'>
|
||||
Select which block's output to return to the user in the chat interface
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='title' className='font-medium text-sm'>
|
||||
Chat Title
|
||||
</Label>
|
||||
<Input
|
||||
id='title'
|
||||
placeholder='Customer Support Assistant'
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
required
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='description' className='font-medium text-sm'>
|
||||
Description (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
placeholder='A brief description of what this chat does'
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
rows={3}
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Chat Output</Label>
|
||||
<Card className='rounded-md border-input shadow-none'>
|
||||
<CardContent className='p-1'>
|
||||
<OutputSelect
|
||||
workflowId={workflowId}
|
||||
selectedOutputs={formData.selectedOutputBlocks}
|
||||
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
|
||||
placeholder='Select which block outputs to use'
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{errors.outputBlocks && (
|
||||
<p className='text-destructive text-sm'>{errors.outputBlocks}</p>
|
||||
)}
|
||||
<p className='mt-2 text-muted-foreground text-xs'>
|
||||
Select which block's output to return to the user in the chat interface
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AuthSelector
|
||||
authType={formData.authType}
|
||||
password={formData.password}
|
||||
emails={formData.emails}
|
||||
onAuthTypeChange={(type) => updateField('authType', type)}
|
||||
onPasswordChange={(password) => updateField('password', password)}
|
||||
onEmailsChange={(emails) => updateField('emails', emails)}
|
||||
disabled={chatSubmitting}
|
||||
isExistingChat={!!existingChat}
|
||||
error={errors.password || errors.emails}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
|
||||
Welcome Message
|
||||
</Label>
|
||||
<Textarea
|
||||
id='welcomeMessage'
|
||||
placeholder='Enter a welcome message for your chat'
|
||||
value={formData.welcomeMessage}
|
||||
onChange={(e) => updateField('welcomeMessage', e.target.value)}
|
||||
rows={3}
|
||||
<AuthSelector
|
||||
authType={formData.authType}
|
||||
password={formData.password}
|
||||
emails={formData.emails}
|
||||
onAuthTypeChange={(type) => updateField('authType', type)}
|
||||
onPasswordChange={(password) => updateField('password', password)}
|
||||
onEmailsChange={(emails) => updateField('emails', emails)}
|
||||
disabled={chatSubmitting}
|
||||
isExistingChat={!!existingChat}
|
||||
error={errors.password || errors.emails}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
|
||||
Welcome Message
|
||||
</Label>
|
||||
<Textarea
|
||||
id='welcomeMessage'
|
||||
placeholder='Enter a welcome message for your chat'
|
||||
value={formData.welcomeMessage}
|
||||
onChange={(e) => updateField('welcomeMessage', e.target.value)}
|
||||
rows={3}
|
||||
disabled={chatSubmitting}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
This message will be displayed when users first open the chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Section */}
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Chat Logo</Label>
|
||||
<ImageUpload
|
||||
value={imageUrl}
|
||||
onUpload={(url) => {
|
||||
setImageUrl(url)
|
||||
setImageUploadError(null) // Clear error on successful upload
|
||||
}}
|
||||
onError={setImageUploadError}
|
||||
onUploadStart={setIsImageUploading}
|
||||
disabled={chatSubmitting}
|
||||
uploadToServer={true}
|
||||
height='h-32'
|
||||
hideHeader={true}
|
||||
/>
|
||||
{imageUploadError && <p className='text-destructive text-sm'>{imageUploadError}</p>}
|
||||
{!imageUrl && !isImageUploading && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Upload a logo for your chat (PNG, JPEG - max 5MB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden delete trigger button for modal footer */}
|
||||
<button
|
||||
type='button'
|
||||
data-delete-trigger
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
This message will be displayed when users first open the chat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat deployment at{' '}
|
||||
<span className='font-mono text-destructive'>
|
||||
{existingChat?.subdomain}.{getEmailDomain()}
|
||||
</span>
|
||||
.
|
||||
<span className='mt-2 block'>
|
||||
All users will lose access immediately, and this action cannot be undone.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
{isDeleting ? (
|
||||
<span className='flex items-center'>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Deleting...
|
||||
</span>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SubdomainInputProps {
|
||||
originalSubdomain?: string
|
||||
disabled?: boolean
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
isEditingExisting?: boolean
|
||||
}
|
||||
|
||||
const getDomainSuffix = (() => {
|
||||
@@ -23,8 +24,13 @@ export function SubdomainInput({
|
||||
originalSubdomain,
|
||||
disabled = false,
|
||||
onValidationChange,
|
||||
isEditingExisting = false,
|
||||
}: SubdomainInputProps) {
|
||||
const { isChecking, error, isValid } = useSubdomainValidation(value, originalSubdomain)
|
||||
const { isChecking, error, isValid } = useSubdomainValidation(
|
||||
value,
|
||||
originalSubdomain,
|
||||
isEditingExisting
|
||||
)
|
||||
|
||||
// Notify parent of validation changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,9 +18,11 @@ interface ExistingChat {
|
||||
interface SuccessViewProps {
|
||||
deployedUrl: string
|
||||
existingChat: ExistingChat | null
|
||||
onDelete?: () => void
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
|
||||
export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: SuccessViewProps) {
|
||||
const url = new URL(deployedUrl)
|
||||
const hostname = url.hostname
|
||||
const isDevelopmentUrl = hostname.includes('localhost')
|
||||
@@ -71,6 +73,10 @@ export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hidden triggers for modal footer buttons */}
|
||||
<button type='button' data-delete-trigger onClick={onDelete} style={{ display: 'none' }} />
|
||||
<button type='button' data-update-trigger onClick={onUpdate} style={{ display: 'none' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useChatDeployment } from './use-chat-deployment'
|
||||
import { useChatForm } from './use-chat-form'
|
||||
import { useImageUpload } from './use-image-upload'
|
||||
|
||||
export { useChatDeployment, useChatForm }
|
||||
export { useChatDeployment, useChatForm, useImageUpload }
|
||||
|
||||
export type { ChatFormData, ChatFormErrors } from './use-chat-form'
|
||||
|
||||
@@ -23,6 +23,7 @@ const chatSchema = z.object({
|
||||
customizations: z.object({
|
||||
primaryColor: z.string(),
|
||||
welcomeMessage: z.string(),
|
||||
imageUrl: z.string().optional(),
|
||||
}),
|
||||
authType: z.enum(['public', 'password', 'email']).default('public'),
|
||||
password: z.string().optional(),
|
||||
@@ -50,7 +51,8 @@ export function useChatDeployment() {
|
||||
workflowId: string,
|
||||
formData: ChatFormData,
|
||||
deploymentInfo: { apiKey: string } | null,
|
||||
existingChatId?: string
|
||||
existingChatId?: string,
|
||||
imageUrl?: string | null
|
||||
) => {
|
||||
setState({ isLoading: true, error: null, deployedUrl: null })
|
||||
|
||||
@@ -79,6 +81,7 @@ export function useChatDeployment() {
|
||||
customizations: {
|
||||
primaryColor: '#802FFF',
|
||||
welcomeMessage: formData.welcomeMessage.trim(),
|
||||
...(imageUrl && { imageUrl }),
|
||||
},
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password : undefined,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ImageUpload')
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
|
||||
|
||||
interface UseImageUploadProps {
|
||||
onUpload?: (url: string | null) => void
|
||||
onError?: (error: string) => void
|
||||
uploadToServer?: boolean
|
||||
}
|
||||
|
||||
export function useImageUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
uploadToServer = false,
|
||||
}: UseImageUploadProps = {}) {
|
||||
const previewRef = useRef<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `File "${file.name}" is too large. Maximum size is 5MB.`
|
||||
}
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
return `File "${file.name}" is not a supported image format. Please use PNG or JPEG.`
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
const handleThumbnailClick = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const uploadFileToServer = useCallback(async (file: File): Promise<string> => {
|
||||
try {
|
||||
// First, try to get a pre-signed URL for direct upload with chat type
|
||||
const presignedResponse = await fetch('/api/files/presigned?type=chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
fileSize: file.size,
|
||||
}),
|
||||
})
|
||||
|
||||
if (presignedResponse.ok) {
|
||||
// Use direct upload with presigned URL
|
||||
const presignedData = await presignedResponse.json()
|
||||
|
||||
// Log the presigned URL response for debugging
|
||||
logger.info('Presigned URL response:', presignedData)
|
||||
|
||||
// Upload directly to storage provider
|
||||
const uploadHeaders: Record<string, string> = {
|
||||
'Content-Type': file.type,
|
||||
}
|
||||
|
||||
// Add any additional headers from the presigned response (for Azure Blob)
|
||||
if (presignedData.uploadHeaders) {
|
||||
Object.assign(uploadHeaders, presignedData.uploadHeaders)
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(presignedData.uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: uploadHeaders,
|
||||
})
|
||||
|
||||
logger.info(`Upload response status: ${uploadResponse.status}`)
|
||||
logger.info(
|
||||
'Upload response headers:',
|
||||
Object.fromEntries(uploadResponse.headers.entries())
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const responseText = await uploadResponse.text()
|
||||
logger.error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
|
||||
throw new Error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
|
||||
}
|
||||
|
||||
// Use the file info returned from the presigned URL endpoint
|
||||
const publicUrl = presignedData.fileInfo.path
|
||||
logger.info(`Image uploaded successfully via direct upload: ${publicUrl}`)
|
||||
return publicUrl
|
||||
}
|
||||
// Fallback to traditional upload through API route
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }))
|
||||
throw new Error(errorData.error || `Failed to upload file: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const publicUrl = data.path
|
||||
logger.info(`Image uploaded successfully via server upload: ${publicUrl}`)
|
||||
return publicUrl
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to upload image')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
// Validate file first
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setFileName(file.name)
|
||||
|
||||
// Always create preview URL
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
setPreviewUrl(previewUrl)
|
||||
previewRef.current = previewUrl
|
||||
|
||||
if (uploadToServer) {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const serverUrl = await uploadFileToServer(file)
|
||||
onUpload?.(serverUrl)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upload image'
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
} else {
|
||||
onUpload?.(previewUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUpload, onError, uploadToServer, uploadFileToServer, validateFile]
|
||||
)
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
setPreviewUrl(null)
|
||||
setFileName(null)
|
||||
previewRef.current = null
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
onUpload?.(null) // Notify parent that image was removed
|
||||
}, [previewUrl, onUpload])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
fileName,
|
||||
fileInputRef,
|
||||
handleThumbnailClick,
|
||||
handleFileChange,
|
||||
handleRemove,
|
||||
isUploading,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useSubdomainValidation(subdomain: string, originalSubdomain?: string) {
|
||||
export function useSubdomainValidation(
|
||||
subdomain: string,
|
||||
originalSubdomain?: string,
|
||||
isEditingExisting?: boolean
|
||||
) {
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
@@ -18,12 +22,20 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st
|
||||
setIsValid(false)
|
||||
setIsChecking(false)
|
||||
|
||||
// Skip validation if empty or same as original
|
||||
// Skip validation if empty
|
||||
if (!subdomain.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (subdomain === originalSubdomain) {
|
||||
// Skip validation if same as original (existing deployment)
|
||||
if (originalSubdomain && subdomain === originalSubdomain) {
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
|
||||
// If we're editing an existing deployment but originalSubdomain isn't available yet,
|
||||
// assume it's valid and wait for the data to load
|
||||
if (isEditingExisting && !originalSubdomain) {
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
@@ -64,7 +76,7 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [subdomain, originalSubdomain])
|
||||
}, [subdomain, originalSubdomain, isEditingExisting])
|
||||
|
||||
return { isChecking, error, isValid }
|
||||
}
|
||||
|
||||
@@ -378,7 +378,13 @@ export function DeployModal({
|
||||
const handleChatFormSubmit = () => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
form.requestSubmit()
|
||||
// Check if we're in success view and need to trigger update
|
||||
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
|
||||
if (updateTrigger) {
|
||||
updateTrigger.click()
|
||||
} else {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +480,7 @@ export function DeployModal({
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onPreDeployWorkflow={handleWorkflowPreDeploy}
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -516,29 +523,57 @@ export function DeployModal({
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleChatFormSubmit}
|
||||
disabled={chatSubmitting || !isChatFormValid}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
<div className='flex gap-2'>
|
||||
{chatExists && (
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
const deleteButton = form.querySelector(
|
||||
'[data-delete-trigger]'
|
||||
) as HTMLButtonElement
|
||||
if (deleteButton) {
|
||||
deleteButton.click()
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={chatSubmitting}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-red-500 hover:bg-red-600',
|
||||
'shadow-[0_0_0_0_rgb(239,68,68)] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-red-500 disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{chatSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Deploying...
|
||||
</>
|
||||
) : chatExists ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Deploy Chat'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleChatFormSubmit}
|
||||
disabled={chatSubmitting || !isChatFormValid}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{chatSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Deploying...
|
||||
</>
|
||||
) : chatExists ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Deploy Chat'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
230
apps/sim/components/ui/image-upload.tsx
Normal file
230
apps/sim/components/ui/image-upload.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ImagePlus, Loader2, Trash2, Upload, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useImageUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-image-upload'
|
||||
|
||||
interface ImageUploadProps {
|
||||
onUpload?: (url: string | null) => void
|
||||
onError?: (error: string) => void
|
||||
onUploadStart?: (isUploading: boolean) => void
|
||||
title?: string
|
||||
description?: string
|
||||
height?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
acceptedFormats?: string[]
|
||||
uploadToServer?: boolean
|
||||
value?: string | null
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function ImageUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
onUploadStart,
|
||||
title = 'Logo Image',
|
||||
description = 'PNG or JPEG (max 5MB)',
|
||||
height = 'h-64',
|
||||
className,
|
||||
disabled = false,
|
||||
acceptedFormats = ['image/png', 'image/jpeg', 'image/jpg'],
|
||||
uploadToServer = false,
|
||||
value,
|
||||
hideHeader = false,
|
||||
}: ImageUploadProps) {
|
||||
const {
|
||||
previewUrl,
|
||||
fileName,
|
||||
fileInputRef,
|
||||
handleThumbnailClick,
|
||||
handleFileChange,
|
||||
handleRemove,
|
||||
isUploading,
|
||||
} = useImageUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
uploadToServer,
|
||||
})
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Use value prop if provided, otherwise use internal previewUrl
|
||||
const displayUrl = value || previewUrl
|
||||
const isDisabled = disabled || isUploading
|
||||
|
||||
// Notify parent when upload status changes
|
||||
useEffect(() => {
|
||||
onUploadStart?.(isUploading)
|
||||
}, [isUploading, onUploadStart])
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (isDisabled) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const fakeEvent = {
|
||||
target: {
|
||||
files: [file],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
handleFileChange(fakeEvent)
|
||||
}
|
||||
},
|
||||
[isDisabled, handleFileChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
hideHeader
|
||||
? 'w-full space-y-4'
|
||||
: 'w-full max-w-md space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<div className='space-y-2'>
|
||||
<h3 className='font-medium text-lg'>{title}</h3>
|
||||
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type='file'
|
||||
accept={acceptedFormats.join(',')}
|
||||
className='hidden'
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{!displayUrl ? (
|
||||
<div
|
||||
onClick={
|
||||
isDisabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleThumbnailClick()
|
||||
}
|
||||
}
|
||||
onDragOver={isDisabled ? undefined : handleDragOver}
|
||||
onDragEnter={isDisabled ? undefined : handleDragEnter}
|
||||
onDragLeave={isDisabled ? undefined : handleDragLeave}
|
||||
onDrop={isDisabled ? undefined : handleDrop}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border-2 border-muted-foreground/25 border-dashed bg-muted/50 transition-colors',
|
||||
height,
|
||||
!isDisabled && 'hover:bg-muted',
|
||||
isDragging && !isDisabled && 'border-primary/50 bg-primary/5',
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className='rounded-full bg-background p-3 shadow-sm'>
|
||||
{isUploading ? (
|
||||
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
|
||||
) : (
|
||||
<ImagePlus className='h-6 w-6 text-muted-foreground' />
|
||||
)}
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-sm'>
|
||||
{isUploading ? 'Uploading...' : 'Click to select'}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{isUploading ? 'Please wait' : 'or drag and drop file here'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : displayUrl ? (
|
||||
<div className='relative'>
|
||||
<div className={cn('group relative overflow-hidden rounded-lg border', height)}>
|
||||
<Image
|
||||
src={displayUrl}
|
||||
alt='Preview'
|
||||
fill
|
||||
className='object-cover transition-transform duration-300 group-hover:scale-105'
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
|
||||
/>
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
<div className='absolute inset-0 flex items-center justify-center gap-2 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleThumbnailClick()
|
||||
}}
|
||||
className='h-9 w-9 p-0'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Upload className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleRemove()
|
||||
}}
|
||||
className='h-9 w-9 p-0'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fileName && (
|
||||
<div className='mt-2 flex items-center gap-2 text-muted-foreground text-sm'>
|
||||
<span className='truncate'>{fileName}</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleRemove()
|
||||
}}
|
||||
className='ml-auto rounded-full p-1 hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export {
|
||||
useFormField,
|
||||
} from './form'
|
||||
export { formatDisplayText } from './formatted-text'
|
||||
export { ImageUpload } from './image-upload'
|
||||
export { Input } from './input'
|
||||
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp'
|
||||
export { OTPInputForm } from './input-otp-form'
|
||||
|
||||
@@ -8,10 +8,38 @@ import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
images: {
|
||||
domains: [
|
||||
'avatars.githubusercontent.com',
|
||||
'oaidalleapiprodscus.blob.core.windows.net',
|
||||
'api.stability.ai',
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.stability.ai',
|
||||
},
|
||||
// Azure Blob Storage
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.blob.core.windows.net',
|
||||
},
|
||||
// AWS S3 - various regions and bucket configurations
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.s3.amazonaws.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.s3.*.amazonaws.com',
|
||||
},
|
||||
// Custom domain for file storage if configured
|
||||
...(env.NEXT_PUBLIC_BLOB_BASE_URL
|
||||
? [
|
||||
{
|
||||
protocol: 'https' as const,
|
||||
hostname: new URL(env.NEXT_PUBLIC_BLOB_BASE_URL).hostname,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
|
||||
@@ -107,7 +107,6 @@ Object.entries(providers).forEach(([id, provider]) => {
|
||||
export function updateOllamaProviderModels(models: string[]): void {
|
||||
updateOllamaModelsInDefinitions(models)
|
||||
providers.ollama.models = getProviderModelsFromDefinitions('ollama')
|
||||
logger.info('Updated Ollama provider models', { models })
|
||||
}
|
||||
|
||||
export function getBaseModelProviders(): Record<string, ProviderId> {
|
||||
|
||||
@@ -8,7 +8,6 @@ const logger = createLogger('OllamaStore')
|
||||
export const useOllamaStore = create<OllamaStore>((set) => ({
|
||||
models: [],
|
||||
setModels: (models) => {
|
||||
logger.info('Updating Ollama models', { models })
|
||||
set({ models })
|
||||
// Update the providers when models change
|
||||
updateOllamaProviderModels(models)
|
||||
|
||||
Reference in New Issue
Block a user