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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Copy Markdown
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
constpresignedData=awaitpresignedResponse.json()
// Log the presigned URL response for debugging
logger.info('Presigned URL response:',presignedData)
// Upload directly to storage provider
constuploadHeaders: 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)
}
constuploadResponse=awaitfetch(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){
constresponseText=awaituploadResponse.text()
logger.error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
thrownewError(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
}
// Use the file info returned from the presigned URL endpoint
constpublicUrl=presignedData.fileInfo.path
logger.info(`Image uploaded successfully via direct upload: ${publicUrl}`)
returnpublicUrl
}
if(presignedResponse.ok){
// Use direct upload with presigned URL
constpresignedData=awaitpresignedResponse.json()
// Validate required fields in presigned response
if(!presignedData.uploadUrl||!presignedData.fileInfo?.path){
thrownewError('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
constuploadHeaders: 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)
}
constuploadResponse=awaitfetch(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){
constresponseText=awaituploadResponse.text()
logger.error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
thrownewError(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
}
// Use the file info returned from the presigned URL endpoint
constpublicUrl=presignedData.fileInfo.path
logger.info(`Image uploaded successfully via direct upload: ${publicUrl}`)
returnpublicUrl
}

// 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
Copy Markdown
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
constpreviewUrl=URL.createObjectURL(file)
setPreviewUrl(previewUrl)
previewRef.current=previewUrl
// Clean up previous preview URL to prevent memory leaks
if(previewRef.current){
URL.revokeObjectURL(previewRef.current)
}
constpreviewUrl=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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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 DownExpand 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