From aaa1c633f60f9c6843d9c51da6d0edf05427b9f5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:15:12 +0000 Subject: [PATCH] Refactor to defer photo uploads --- src/components/upload/PhotoCaptionEditor.tsx | 4 +- .../upload/UppyPhotoSubmissionUpload.tsx | 164 ++++++++++++++++-- src/components/upload/UppyPhotoUpload.tsx | 11 ++ 3 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/components/upload/PhotoCaptionEditor.tsx b/src/components/upload/PhotoCaptionEditor.tsx index eefc14cc..4ad7d7d2 100644 --- a/src/components/upload/PhotoCaptionEditor.tsx +++ b/src/components/upload/PhotoCaptionEditor.tsx @@ -8,10 +8,12 @@ import { X, Eye, GripVertical, Edit3 } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface PhotoWithCaption { - url: string; + url: string; // Object URL for preview, Cloudflare URL after upload + file?: File; // The actual file to upload later caption: string; title?: string; order: number; + uploadStatus?: 'pending' | 'uploading' | 'uploaded' | 'failed'; } interface PhotoCaptionEditorProps { diff --git a/src/components/upload/UppyPhotoSubmissionUpload.tsx b/src/components/upload/UppyPhotoSubmissionUpload.tsx index 65ad909e..765811b8 100644 --- a/src/components/upload/UppyPhotoSubmissionUpload.tsx +++ b/src/components/upload/UppyPhotoSubmissionUpload.tsx @@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; +import { Progress } from '@/components/ui/progress'; import { UppyPhotoUpload } from './UppyPhotoUpload'; import { PhotoCaptionEditor, PhotoWithCaption } from './PhotoCaptionEditor'; import { supabase } from '@/integrations/supabase/client'; @@ -28,15 +29,18 @@ export function UppyPhotoSubmissionUpload({ const [description, setDescription] = useState(''); const [photos, setPhotos] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null); const { user } = useAuth(); const { toast } = useToast(); - const handleUploadComplete = (urls: string[]) => { - // Convert URLs to photo objects with empty captions - const newPhotos: PhotoWithCaption[] = urls.map((url, index) => ({ - url, + const handleFilesSelected = (files: File[]) => { + // Convert files to photo objects with object URLs for preview + const newPhotos: PhotoWithCaption[] = files.map((file, index) => ({ + url: URL.createObjectURL(file), // Object URL for preview + file, // Store the file for later upload caption: '', order: photos.length + index, + uploadStatus: 'pending' as const, })); setPhotos(prev => [...prev, ...newPhotos]); }; @@ -46,7 +50,14 @@ export function UppyPhotoSubmissionUpload({ }; const handleRemovePhoto = (index: number) => { - setPhotos(prev => prev.filter((_, i) => i !== index)); + setPhotos(prev => { + const photo = prev[index]; + // Revoke object URL if it exists + if (photo.file && photo.url.startsWith('blob:')) { + URL.revokeObjectURL(photo.url); + } + return prev.filter((_, i) => i !== index); + }); }; const handleSubmit = async () => { @@ -80,6 +91,105 @@ export function UppyPhotoSubmissionUpload({ setIsSubmitting(true); try { + // Upload all photos that haven't been uploaded yet + const uploadedPhotos: PhotoWithCaption[] = []; + const photosToUpload = photos.filter(p => p.file); + + if (photosToUpload.length > 0) { + setUploadProgress({ current: 0, total: photosToUpload.length }); + + for (let i = 0; i < photosToUpload.length; i++) { + const photo = photosToUpload[i]; + setUploadProgress({ current: i + 1, total: photosToUpload.length }); + + // Update status + setPhotos(prev => prev.map(p => + p === photo ? { ...p, uploadStatus: 'uploading' as const } : p + )); + + try { + // Get upload URL from edge function + const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', { + body: { metadata: { requireSignedURLs: false }, variant: 'public' } + }); + + if (uploadError) throw uploadError; + + const { uploadURL, id: cloudflareId } = uploadData; + + // Upload file to Cloudflare + const formData = new FormData(); + formData.append('file', photo.file); + + const uploadResponse = await fetch(uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to upload to Cloudflare'); + } + + // Poll for processing completion + let attempts = 0; + const maxAttempts = 30; + let cloudflareUrl = ''; + + while (attempts < maxAttempts) { + const { data: { session } } = await supabase.auth.getSession(); + const statusResponse = await fetch( + `https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image?id=${cloudflareId}`, + { + headers: { + 'Authorization': `Bearer ${session?.access_token || ''}`, + 'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4', + } + } + ); + + if (statusResponse.ok) { + const status = await statusResponse.json(); + if (status.uploaded && status.urls) { + cloudflareUrl = status.urls.public; + break; + } + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + attempts++; + } + + if (!cloudflareUrl) { + throw new Error('Upload processing timeout'); + } + + // Revoke object URL + URL.revokeObjectURL(photo.url); + + uploadedPhotos.push({ + ...photo, + url: cloudflareUrl, + uploadStatus: 'uploaded' as const, + }); + + // Update status + setPhotos(prev => prev.map(p => + p === photo ? { ...p, url: cloudflareUrl, uploadStatus: 'uploaded' as const } : p + )); + + } catch (error) { + console.error('Upload error:', error); + setPhotos(prev => prev.map(p => + p === photo ? { ...p, uploadStatus: 'failed' as const } : p + )); + throw new Error(`Failed to upload ${photo.title || 'photo'}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } + + setUploadProgress(null); + + // Submit to database with Cloudflare URLs const submissionData = { user_id: user.id, submission_type: 'photo', @@ -87,7 +197,7 @@ export function UppyPhotoSubmissionUpload({ title: title.trim(), description: description.trim(), photos: photos.map((photo, index) => ({ - url: photo.url, + url: photo.uploadStatus === 'uploaded' ? photo.url : uploadedPhotos.find(p => p.order === photo.order)?.url || photo.url, caption: photo.caption.trim(), title: photo.title?.trim(), order: index, @@ -112,7 +222,13 @@ export function UppyPhotoSubmissionUpload({ description: 'Your photos have been submitted for review. Thank you for contributing!', }); - // Reset form + // Cleanup and reset form + photos.forEach(photo => { + if (photo.url.startsWith('blob:')) { + URL.revokeObjectURL(photo.url); + } + }); + setTitle(''); setDescription(''); setPhotos([]); @@ -122,13 +238,25 @@ export function UppyPhotoSubmissionUpload({ toast({ variant: 'destructive', title: 'Submission Failed', - description: 'There was an error submitting your photos. Please try again.', + description: error instanceof Error ? error.message : 'There was an error submitting your photos. Please try again.', }); } finally { setIsSubmitting(false); + setUploadProgress(null); } }; + // Cleanup on unmount + React.useEffect(() => { + return () => { + photos.forEach(photo => { + if (photo.url.startsWith('blob:')) { + URL.revokeObjectURL(photo.url); + } + }); + }; + }, []); + const metadata = { submissionType: 'photo', parkId, @@ -181,6 +309,7 @@ export function UppyPhotoSubmissionUpload({ placeholder="Give your photos a descriptive title" maxLength={100} required + disabled={isSubmitting} className="transition-all duration-200 focus:ring-2 focus:ring-primary/20" />

@@ -199,6 +328,7 @@ export function UppyPhotoSubmissionUpload({ placeholder="Add a general description about these photos..." maxLength={500} rows={3} + disabled={isSubmitting} className="transition-all duration-200 focus:ring-2 focus:ring-primary/20 resize-none" />

@@ -216,7 +346,8 @@ export function UppyPhotoSubmissionUpload({ )} @@ -242,6 +374,18 @@ export function UppyPhotoSubmissionUpload({

+ {uploadProgress && ( +
+
+ Uploading photos... + + {uploadProgress.current} of {uploadProgress.total} + +
+ +
+ )} +