diff --git a/src/components/upload/UppyPhotoUpload.tsx b/src/components/upload/UppyPhotoUpload.tsx index d37c7f99..c4441255 100644 --- a/src/components/upload/UppyPhotoUpload.tsx +++ b/src/components/upload/UppyPhotoUpload.tsx @@ -1,16 +1,12 @@ -import React, { useEffect, useRef, useState } from 'react'; -import Uppy from '@uppy/core'; -import Dashboard from '@uppy/dashboard'; -import XHRUpload from '@uppy/xhr-upload'; -import ImageEditor from '@uppy/image-editor'; -import { DashboardModal } from '@uppy/react'; +import React, { useRef, useState } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Upload, X, Eye, Loader2 } from 'lucide-react'; +import { Upload, X, Eye, Loader2, CheckCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { DragDropZone } from './DragDropZone'; +import { Progress } from '@/components/ui/progress'; interface UppyPhotoUploadProps { onUploadComplete?: (urls: string[]) => void; @@ -63,282 +59,170 @@ export function UppyPhotoUpload({ showPreview = true, size = 'default', enableDragDrop = true, - showUploadModal = true, }: UppyPhotoUploadProps) { - const [isModalOpen, setIsModalOpen] = useState(false); const [uploadedImages, setUploadedImages] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); - const uppyRef = useRef(null); - const uploadedImagesRef = useRef([]); + const [currentFileName, setCurrentFileName] = useState(''); + const fileInputRef = useRef(null); const { toast } = useToast(); - useEffect(() => { - // Initialize Uppy - const uppy = new Uppy({ - restrictions: { - maxNumberOfFiles: maxFiles, - allowedFileTypes, - maxFileSize: maxSizeMB * 1024 * 1024, - }, - autoProceed: false, - allowMultipleUploadBatches: true, - }); + const validateFile = (file: File): string | null => { + const maxSize = maxSizeMB * 1024 * 1024; + if (file.size > maxSize) { + return `File "${file.name}" exceeds ${maxSizeMB}MB limit`; + } - // Add Dashboard plugin - uppy.use(Dashboard, { - inline: false, - trigger: null, - hideProgressDetails: false, - proudlyDisplayPoweredByUppy: false, - showRemoveButtonAfterComplete: true, - showSelectedFiles: true, - note: `Images up to ${maxSizeMB}MB, max ${maxFiles} files`, - theme: 'auto', - closeAfterFinish: true, - closeModalOnClickOutside: true, - disablePageScrollWhenModalOpen: true, - animateOpenClose: true, - }); - - // Add Image Editor plugin - uppy.use(ImageEditor, { - quality: 0.8, - cropperOptions: { - viewMode: 1, - background: false, - autoCropArea: 1, - }, - }); - - // Add XHR Upload plugin (configured per file in beforeUpload) - uppy.use(XHRUpload, { - endpoint: '', // Will be set dynamically - method: 'POST', - formData: true, - bundle: false, - }); - - // Before upload hook - fetch upload URL for each file - uppy.on('files-added', async (files) => { - if (disabled) { - uppy.cancelAll(); - return; + const allowedTypes = allowedFileTypes.map(type => + type.replace('*', '').replace('/', '') + ); + + if (!allowedTypes.includes('image') && !allowedFileTypes.includes('image/*')) { + const fileType = file.type.split('/')[0]; + if (!allowedTypes.includes(fileType)) { + return `File type "${file.type}" is not allowed`; } + } - setIsUploading(true); - setUploadProgress(0); - onUploadStart?.(); + return null; + }; - // Process all files in parallel to get upload URLs - const uploadPromises = files.map(async (file) => { - try { - const response = await supabase.functions.invoke('upload-image', { - body: { metadata, variant }, - }); + const uploadSingleFile = async (file: File): Promise => { + setCurrentFileName(file.name); - if (response.error) { - throw new Error(response.error.message); - } + // Step 1: Get upload URL from Supabase edge function + const urlResponse = await supabase.functions.invoke('upload-image', { + body: { metadata, variant }, + }); - const result: CloudflareResponse = response.data; - - // Store Cloudflare ID in file meta - uppy.setFileMeta(file.id, { - ...file.meta, - cloudflareId: result.id, - }); + if (urlResponse.error) { + throw new Error(`Failed to get upload URL: ${urlResponse.error.message}`); + } - // Configure XHR upload endpoint for this file - const xhrPlugin = uppy.getPlugin('XHRUpload') as any; - if (xhrPlugin) { - xhrPlugin.setOptions({ - endpoint: result.uploadURL, - }); - } + const { uploadURL, id: cloudflareId }: CloudflareResponse = urlResponse.data; - return { success: true, fileId: file.id }; - } catch (error) { - console.error('Failed to get upload URL:', error); - uppy.removeFile(file.id); - toast({ - variant: 'destructive', - title: 'Upload Error', - description: `Failed to prepare upload for ${file.name}`, - }); - onUploadError?.(error as Error); - return { success: false, fileId: file.id }; - } + // Step 2: Upload file directly to Cloudflare + const formData = new FormData(); + formData.append('file', file); + + const uploadResponse = await fetch(uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`Cloudflare upload failed: ${uploadResponse.statusText}`); + } + + // Step 3: Poll for processing completion + let attempts = 0; + const maxAttempts = 30; + + while (attempts < maxAttempts) { + const statusResponse = await supabase.functions.invoke('upload-image', { + body: { id: cloudflareId }, }); - // Wait for all URL fetching to complete - const results = await Promise.all(uploadPromises); - - // Check if any files are ready to upload - const successfulFiles = results.filter(r => r.success); - if (successfulFiles.length > 0) { - // All URLs are fetched, now trigger the upload - uppy.upload(); - } else { - // No files ready, reset state - setIsUploading(false); - setUploadProgress(0); - } - }); - - // Handle upload progress - uppy.on('progress', (progress) => { - setUploadProgress(Math.round(progress)); - }); - - // Handle upload success - uppy.on('upload-success', async (file, response) => { - try { - const cloudflareId = file?.meta?.cloudflareId as string; - if (!cloudflareId) { - throw new Error('Missing Cloudflare ID'); - } - - // Poll for upload completion with the correct ID - let attempts = 0; - const maxAttempts = 30; // 30 seconds max - let imageData: UploadSuccessResponse | null = null; - - while (attempts < maxAttempts) { - const statusResponse = await supabase.functions.invoke('upload-image', { - body: { id: cloudflareId }, - }); - - if (!statusResponse.error && statusResponse.data) { - const status: UploadSuccessResponse = statusResponse.data; - if (status.uploaded && status.urls) { - imageData = status; - break; - } + if (!statusResponse.error && statusResponse.data) { + const status: UploadSuccessResponse = statusResponse.data; + if (status.uploaded && status.urls) { + return status.urls.public; } - - await new Promise(resolve => setTimeout(resolve, 1000)); - attempts++; } - if (imageData?.urls) { - const newUrl = imageData.urls.public; - uploadedImagesRef.current = [...uploadedImagesRef.current, newUrl]; - setUploadedImages(prev => [...prev, newUrl]); - } - } catch (error) { - console.error('Upload post-processing failed:', error); - onUploadError?.(error as Error); + await new Promise(resolve => setTimeout(resolve, 1000)); + attempts++; } - }); - // Handle upload error - uppy.on('upload-error', (file, error, response) => { - console.error('Upload error:', error); - setIsUploading(false); - - // Check if it's an expired URL error and retry - if (error.message?.includes('expired') || response?.status === 400) { + throw new Error('Upload processing timeout'); + }; + + const handleFiles = async (files: File[]) => { + if (disabled || isUploading) return; + + // Validate file count + const remainingSlots = maxFiles - uploadedImages.length; + if (files.length > remainingSlots) { + toast({ + variant: 'destructive', + title: 'Too Many Files', + description: `You can only upload ${remainingSlots} more file(s). Maximum is ${maxFiles}.`, + }); + return; + } + + // Validate each file + for (const file of files) { + const error = validateFile(file); + if (error) { toast({ - title: 'Upload URL Expired', - description: 'Retrying upload...', + variant: 'destructive', + title: 'Invalid File', + description: error, }); - - // Retry the upload by re-adding the file - if (file) { - uppy.removeFile(file.id); - // Re-add the file to trigger new URL fetch - uppy.addFile({ - source: 'retry', - name: file.name, - type: file.type, - data: file.data, - meta: { ...file.meta, cloudflareId: undefined }, - }); - } - } else { + return; + } + } + + setIsUploading(true); + setUploadProgress(0); + onUploadStart?.(); + + const newUrls: string[] = []; + const totalFiles = files.length; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + try { + const url = await uploadSingleFile(file); + newUrls.push(url); + setUploadProgress(Math.round(((i + 1) / totalFiles) * 100)); + } catch (error) { + console.error(`Upload failed for ${file.name}:`, error); toast({ variant: 'destructive', title: 'Upload Failed', - description: error.message || 'An error occurred during upload', + description: `Failed to upload "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, }); - onUploadError?.(error); + onUploadError?.(error as Error); } - }); + } - // Handle upload complete - uppy.on('complete', (result) => { - setIsUploading(false); - setUploadProgress(0); + if (newUrls.length > 0) { + const allUrls = [...uploadedImages, ...newUrls]; + setUploadedImages(allUrls); + onUploadComplete?.(allUrls); - if (result.successful.length > 0) { - // Use ref to get current uploaded images - onUploadComplete?.(uploadedImagesRef.current); - toast({ - title: 'Upload Complete', - description: `Successfully uploaded ${result.successful.length} image(s)`, - }); - - // Clear Uppy's file list for next batch - uppy.cancelAll(); - } - setIsModalOpen(false); - }); - - uppyRef.current = uppy; - - return () => { - uppy.destroy(); - }; - }, [maxFiles, maxSizeMB, allowedFileTypes, metadata, variant, disabled, onUploadStart, onUploadComplete, onUploadError, toast, size]); - - const handleDragDropFiles = async (files: File[]) => { - if (!uppyRef.current || disabled) return; - - try { - // Add files to Uppy - the files-added event will handle URL fetching and upload - files.forEach((file) => { - try { - uppyRef.current?.addFile({ - source: 'drag-drop', - name: file.name, - type: file.type, - data: file, - }); - } catch (error: any) { - // Handle duplicate file error gracefully - if (error.message?.includes('duplicate')) { - console.log(`Skipping duplicate file: ${file.name}`); - } else { - throw error; - } - } - }); - - // Don't call upload() here - the files-added handler will trigger it after URLs are ready - } catch (error) { - console.error('Error adding files:', error); toast({ - variant: 'destructive', - title: 'Error', - description: 'Failed to add files for upload', + title: 'Upload Complete', + description: `Successfully uploaded ${newUrls.length} image(s)`, }); } + + setIsUploading(false); + setUploadProgress(0); + setCurrentFileName(''); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + handleFiles(files); + } + // Reset input + e.target.value = ''; + }; + + const triggerFileSelect = () => { + if (!disabled && !isUploading) { + fileInputRef.current?.click(); + } }; const removeImage = (index: number) => { const newUrls = uploadedImages.filter((_, i) => i !== index); - uploadedImagesRef.current = newUrls; setUploadedImages(newUrls); - if (onUploadComplete) { - onUploadComplete(newUrls); - } - }; - - const handleOpenModal = () => { - if (!disabled) { - setIsModalOpen(true); - } + onUploadComplete?.(newUrls); }; const getSizeClasses = () => { @@ -355,7 +239,7 @@ export function UppyPhotoUpload({ const renderUploadTrigger = () => { if (children) { return ( -
+
{children}
); @@ -367,7 +251,7 @@ export function UppyPhotoUpload({ return (