mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 15:11:12 -05:00
Fix photo uploads
This commit is contained in:
@@ -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<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const uppyRef = useRef<Uppy | null>(null);
|
||||
const uploadedImagesRef = useRef<string[]>([]);
|
||||
const [currentFileName, setCurrentFileName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<string> => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div onClick={handleOpenModal} className="cursor-pointer">
|
||||
<div onClick={triggerFileSelect} className="cursor-pointer">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -367,7 +251,7 @@ export function UppyPhotoUpload({
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleOpenModal}
|
||||
onClick={triggerFileSelect}
|
||||
disabled={disabled || isUploading}
|
||||
className={cn(baseClasses, sizeClasses, disabledClasses)}
|
||||
size={size === 'compact' ? 'sm' : size === 'large' ? 'lg' : 'default'}
|
||||
@@ -431,18 +315,43 @@ export function UppyPhotoUpload({
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoadingState = () => {
|
||||
if (!isUploading) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-4 bg-muted/50 rounded-lg border border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="text-sm font-medium">Uploading...</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{uploadProgress}%
|
||||
</Badge>
|
||||
</div>
|
||||
{currentFileName && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{currentFileName}
|
||||
</p>
|
||||
)}
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (enableDragDrop && !children) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragDropZone
|
||||
onFilesAdded={handleDragDropFiles}
|
||||
onFilesAdded={handleFiles}
|
||||
maxFiles={maxFiles}
|
||||
maxSizeMB={maxSizeMB}
|
||||
allowedFileTypes={allowedFileTypes}
|
||||
disabled={disabled || isUploading}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
{renderLoadingState()}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
);
|
||||
@@ -452,17 +361,18 @@ export function UppyPhotoUpload({
|
||||
<div className="space-y-4">
|
||||
{enableDragDrop && children ? (
|
||||
<DragDropZone
|
||||
onFilesAdded={handleDragDropFiles}
|
||||
onFilesAdded={handleFiles}
|
||||
maxFiles={maxFiles}
|
||||
maxSizeMB={maxSizeMB}
|
||||
allowedFileTypes={allowedFileTypes}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
{renderUploadTrigger()}
|
||||
</DragDropZone>
|
||||
) : (
|
||||
renderUploadTrigger()
|
||||
)}
|
||||
{renderLoadingState()}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
);
|
||||
@@ -470,18 +380,15 @@ export function UppyPhotoUpload({
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={allowedFileTypes.join(',')}
|
||||
multiple={maxFiles > 1}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{renderContent()}
|
||||
|
||||
{uppyRef.current && showUploadModal && (
|
||||
<DashboardModal
|
||||
uppy={uppyRef.current}
|
||||
open={isModalOpen}
|
||||
onRequestClose={() => setIsModalOpen(false)}
|
||||
closeModalOnClickOutside
|
||||
animateOpenClose
|
||||
browserBackButtonClose
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user