import React, { useRef, useState } from 'react'; import { supabase } from '@/lib/supabaseClient'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { handleError, getErrorMessage } from '@/lib/errorHandler'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; 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; onFilesSelected?: (files: File[]) => 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'; enableDragDrop?: boolean; showUploadModal?: boolean; deferUpload?: boolean; // If true, don't upload immediately } 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, onFilesSelected, onUploadStart, onUploadError, maxFiles = 5, maxSizeMB = 10, allowedFileTypes = ['image/*'], metadata = {}, variant = 'public', className = '', children, disabled = false, showPreview = true, size = 'default', enableDragDrop = true, deferUpload = false, }: UppyPhotoUploadProps) { const [uploadedImages, setUploadedImages] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [currentFileName, setCurrentFileName] = useState(''); const fileInputRef = useRef(null); const { toast } = useToast(); const validateFile = (file: File): string | null => { const maxSize = maxSizeMB * 1024 * 1024; if (file.size > maxSize) { return `File "${file.name}" exceeds ${maxSizeMB}MB limit`; } // Check if file type is allowed // Support both wildcard (image/*) and specific types (image/jpeg, image/png) const isWildcardMatch = allowedFileTypes.some(type => { if (type.includes('*')) { const prefix = type.split('/')[0]; return file.type.startsWith(prefix + '/'); } return false; }); const isExactMatch = allowedFileTypes.includes(file.type); if (!isWildcardMatch && !isExactMatch) { return `File type "${file.type}" is not allowed`; } return null; }; const uploadSingleFile = async (file: File): Promise => { setCurrentFileName(file.name); // Step 1: Get upload URL from Supabase edge function const urlResponse = await invokeWithTracking('upload-image', { metadata, variant }, undefined); if (urlResponse.error) { throw new Error(`Failed to get upload URL: ${urlResponse.error.message}`); } const { uploadURL, id: cloudflareId }: CloudflareResponse = urlResponse.data; // 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 { data: { session } } = await supabase.auth.getSession(); const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://api.thrillwiki.com'; const statusResponse = await fetch( `${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, { headers: { 'Authorization': `Bearer ${session?.access_token || ''}`, 'apikey': import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4', } } ); if (statusResponse.ok) { const status: UploadSuccessResponse = await statusResponse.json(); if (status.uploaded && status.urls) { return `https://cdn.thrillwiki.com/images/${cloudflareId}/public`; } } await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } 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({ variant: 'destructive', title: 'Invalid File', description: error, }); return; } } // If deferUpload is true, just notify and don't upload if (deferUpload) { onFilesSelected?.(files); return; } // Otherwise, upload immediately (old behavior) 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: unknown) { const errorMsg = getErrorMessage(error); handleError(error, { action: 'Upload Photo File', metadata: { fileName: file.name, fileSize: file.size, fileType: file.type } }); toast({ variant: 'destructive', title: 'Upload Failed', description: `Failed to upload "${file.name}": ${errorMsg}`, }); onUploadError?.(error as Error); } } if (newUrls.length > 0) { const allUrls = [...uploadedImages, ...newUrls]; setUploadedImages(allUrls); onUploadComplete?.(allUrls); toast({ 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); setUploadedImages(newUrls); onUploadComplete?.(newUrls); }; 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
))}
); }; const renderLoadingState = () => { if (!isUploading) return null; return (
Uploading...
{uploadProgress}%
{currentFileName && (

{currentFileName}

)}
); }; const renderContent = () => { if (enableDragDrop && !children) { return (
{renderLoadingState()} {renderPreview()}
); } return (
{enableDragDrop && children ? ( {renderUploadTrigger()} ) : ( renderUploadTrigger() )} {renderLoadingState()} {renderPreview()}
); }; return (
1} onChange={handleFileSelect} className="hidden" /> {renderContent()}
); }