-
Notifications
You must be signed in to change notification settings - Fork 3.3k
fix(chat-deploy): added new image upload component, fixed some state issues with success view #842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||
|
Comment on lines
+131
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: This optimistic validation could lead to a race condition. If |
||
|
|
@@ -64,7 +76,7 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st | |
| clearTimeout(timeoutRef.current) | ||
| } | ||
| } | ||
| }, [subdomain, originalSubdomain]) | ||
| }, [subdomain, originalSubdomain, isEditingExisting]) | ||
|
|
||
| return { isChecking, error, isValid } | ||
| } | ||
There was a problem hiding this comment.
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.uploadUrlorpresignedData.fileInfo.pathare missing from response