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 { 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 { cn } from '@/lib/utils'; interface UppyPhotoUploadProps { onUploadComplete?: (urls: string[]) => void; onUploadStart?: () => void; onUploadError?: (error: Error) => void; maxFiles?: number; maxSizeMB?: number; allowedFileTypes?: string[]; metadata?: Record; variant?: string; className?: string; children?: React.ReactNode; disabled?: boolean; showPreview?: boolean; size?: 'default' | 'compact' | 'large'; } interface CloudflareResponse { uploadURL: string; id: string; } interface UploadSuccessResponse { success: boolean; id: string; uploaded: boolean; urls?: { public: string; thumbnail: string; medium: string; large: string; avatar: string; }; } export function UppyPhotoUpload({ onUploadComplete, onUploadStart, onUploadError, maxFiles = 5, maxSizeMB = 10, allowedFileTypes = ['image/*'], metadata = {}, variant = 'public', className = '', children, disabled = false, showPreview = true, size = 'default', }: 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 { toast } = useToast(); useEffect(() => { // Initialize Uppy const uppy = new Uppy({ restrictions: { maxNumberOfFiles: maxFiles, allowedFileTypes, maxFileSize: maxSizeMB * 1024 * 1024, }, autoProceed: false, }); // 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, }); // 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; } setIsUploading(true); setUploadProgress(0); onUploadStart?.(); // Process each file to get upload URL for (const file of files) { try { const response = await supabase.functions.invoke('upload-image', { body: { metadata, variant }, }); if (response.error) { throw new Error(response.error.message); } const result: CloudflareResponse = response.data; // Store Cloudflare ID in file meta uppy.setFileMeta(file.id, { ...file.meta, cloudflareId: result.id, }); // Configure XHR upload endpoint for this file const xhrPlugin = uppy.getPlugin('XHRUpload') as any; if (xhrPlugin) { xhrPlugin.setOptions({ endpoint: result.uploadURL, }); } } 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); } } }); // 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 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: null, method: 'GET', }); if (!statusResponse.error && statusResponse.data) { const status: UploadSuccessResponse = statusResponse.data; if (status.uploaded && status.urls) { imageData = status; break; } } await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } if (imageData?.urls) { setUploadedImages(prev => [...prev, imageData.urls!.public]); } } catch (error) { console.error('Upload post-processing failed:', error); onUploadError?.(error as Error); } }); // 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) { toast({ title: 'Upload URL Expired', description: 'Retrying upload...', }); // 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 { toast({ variant: 'destructive', title: 'Upload Failed', description: error.message || 'An error occurred during upload', }); onUploadError?.(error); } }); // Handle upload complete uppy.on('complete', (result) => { setIsUploading(false); setUploadProgress(0); if (result.successful.length > 0) { onUploadComplete?.(uploadedImages); toast({ title: 'Upload Complete', description: `Successfully uploaded ${result.successful.length} image(s)`, }); } setIsModalOpen(false); }); uppyRef.current = uppy; return () => { uppy.destroy(); }; }, [maxFiles, maxSizeMB, allowedFileTypes, metadata, variant, disabled, onUploadStart, onUploadComplete, onUploadError, toast, uploadedImages, size]); const removeImage = (index: number) => { const newUrls = uploadedImages.filter((_, i) => i !== index); setUploadedImages(newUrls); if (onUploadComplete) { onUploadComplete(newUrls); } }; const handleOpenModal = () => { if (!disabled) { setIsModalOpen(true); } }; const getSizeClasses = () => { switch (size) { case 'compact': return 'px-4 py-2 text-sm'; case 'large': return 'px-8 py-4 text-lg'; default: return 'px-6 py-3'; } }; const renderUploadTrigger = () => { if (children) { return (
{children}
); } const baseClasses = "photo-upload-trigger transition-all duration-300 flex items-center justify-center gap-2"; const sizeClasses = getSizeClasses(); const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:scale-105'; return ( ); }; const renderPreview = () => { if (!showPreview || uploadedImages.length === 0) return null; return (
Uploaded Photos ({uploadedImages.length}) {uploadedImages.length}/{maxFiles}
{uploadedImages.map((url, index) => (
{`Uploaded
))}
); }; return (
{renderUploadTrigger()} {renderPreview()} {uppyRef.current && ( setIsModalOpen(false)} closeModalOnClickOutside animateOpenClose browserBackButtonClose /> )}
); }