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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface SubdomainInputProps {
originalSubdomain?: string
disabled?: boolean
onValidationChange?: (isValid: boolean) => void
isEditingExisting?: boolean
}

const getDomainSuffix = (() => {
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 })

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +54 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: No error handling if presignedData.uploadUrl or presignedData.fileInfo.path are missing from response

Suggested change
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
}
if (presignedResponse.ok) {
// Use direct upload with presigned URL
const presignedData = await presignedResponse.json()
// Validate required fields in presigned response
if (!presignedData.uploadUrl || !presignedData.fileInfo?.path) {
throw new Error('Invalid presigned response: missing uploadUrl or fileInfo.path')
}
// 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
Comment on lines +131 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Creates object URL but doesn't immediately clean up previous one when file changes

Suggested change
const previewUrl = URL.createObjectURL(file)
setPreviewUrl(previewUrl)
previewRef.current = previewUrl
// Clean up previous preview URL to prevent memory leaks
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
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,
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
Comment on lines +3 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider grouping the optional parameters into an options object for better maintainability as the function signature grows.

Context Used: Context - Group optional parameters into an options object for better maintainability when defining functions or methods. (link)

const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
Expand All @@ -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
}
Comment on lines +38 to 41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This optimistic validation could lead to a race condition. If originalSubdomain loads after the user has already changed the subdomain, this early return might prevent proper validation of the new value.

Expand Down Expand Up @@ -64,7 +76,7 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st
clearTimeout(timeoutRef.current)
}
}
}, [subdomain, originalSubdomain])
}, [subdomain, originalSubdomain, isEditingExisting])

return { isChecking, error, isValid }
}
Loading