diff --git a/src/components/admin/DesignerForm.tsx b/src/components/admin/DesignerForm.tsx index dbb033be..05ff35a8 100644 --- a/src/components/admin/DesignerForm.tsx +++ b/src/components/admin/DesignerForm.tsx @@ -25,11 +25,13 @@ const designerSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional() })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional() + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional() }).optional() }); diff --git a/src/components/admin/ManufacturerForm.tsx b/src/components/admin/ManufacturerForm.tsx index 54802be0..c3a4ad36 100644 --- a/src/components/admin/ManufacturerForm.tsx +++ b/src/components/admin/ManufacturerForm.tsx @@ -25,11 +25,13 @@ const manufacturerSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional() })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional() + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional() }).optional() }); diff --git a/src/components/admin/OperatorForm.tsx b/src/components/admin/OperatorForm.tsx index e56471a6..246427cf 100644 --- a/src/components/admin/OperatorForm.tsx +++ b/src/components/admin/OperatorForm.tsx @@ -25,11 +25,13 @@ const operatorSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional() })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional() + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional() }).optional() }); diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index fb4b05e1..46ea8be8 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -35,11 +35,13 @@ const parkSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional(), })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional(), + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional(), }).optional() }); diff --git a/src/components/admin/PropertyOwnerForm.tsx b/src/components/admin/PropertyOwnerForm.tsx index bd1986d0..9a2717c6 100644 --- a/src/components/admin/PropertyOwnerForm.tsx +++ b/src/components/admin/PropertyOwnerForm.tsx @@ -25,11 +25,13 @@ const propertyOwnerSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional() })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional() + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional() }).optional() }); diff --git a/src/components/admin/RideForm.tsx b/src/components/admin/RideForm.tsx index 64920a72..897fd95b 100644 --- a/src/components/admin/RideForm.tsx +++ b/src/components/admin/RideForm.tsx @@ -66,11 +66,13 @@ const rideSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional(), })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional(), + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional(), }).optional() }); diff --git a/src/components/admin/RideModelForm.tsx b/src/components/admin/RideModelForm.tsx index 400b8f34..973ebfa1 100644 --- a/src/components/admin/RideModelForm.tsx +++ b/src/components/admin/RideModelForm.tsx @@ -23,11 +23,13 @@ const rideModelSchema = z.object({ images: z.object({ uploaded: z.array(z.object({ url: z.string(), - cloudflare_id: z.string(), + cloudflare_id: z.string().optional(), + file: z.any().optional(), + isLocal: z.boolean().optional(), caption: z.string().optional() })), - banner_assignment: z.number().optional(), - card_assignment: z.number().optional() + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional() }).optional() }); diff --git a/src/components/upload/EntityMultiImageUploader.tsx b/src/components/upload/EntityMultiImageUploader.tsx index 4cbaa866..2cab51a1 100644 --- a/src/components/upload/EntityMultiImageUploader.tsx +++ b/src/components/upload/EntityMultiImageUploader.tsx @@ -1,21 +1,27 @@ -import { useState } from 'react'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; +import React, { useState, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { ImageIcon, Star, CreditCard, X } from 'lucide-react'; -import { UppyPhotoUpload } from './UppyPhotoUpload'; +import { Upload, Star, CreditCard, Trash2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; export interface UploadedImage { url: string; - cloudflare_id: string; + cloudflare_id?: string; + file?: File; + isLocal?: boolean; caption?: string; } export interface ImageAssignments { uploaded: UploadedImage[]; - banner_assignment?: number; - card_assignment?: number; + banner_assignment?: number | null; + card_assignment?: number | null; } interface EntityMultiImageUploaderProps { @@ -32,61 +38,89 @@ export function EntityMultiImageUploader({ onChange, entityType = 'entity' }: EntityMultiImageUploaderProps) { - const [showUploader, setShowUploader] = useState(false); - const maxImages = mode === 'create' ? 5 : 3; - const canUploadMore = value.uploaded.length < maxImages; + const fileInputRef = useRef(null); - const handleUploadComplete = (urls: string[]) => { - const newImages: UploadedImage[] = urls.map(url => ({ - url, - cloudflare_id: url.split('/').pop()?.split('?')[0] || '' + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const currentCount = value.uploaded.length; + const availableSlots = maxImages - currentCount; + + if (availableSlots <= 0) { + return; + } + + const filesToAdd = Array.from(files).slice(0, availableSlots); + const newImages: UploadedImage[] = filesToAdd.map(file => ({ + url: URL.createObjectURL(file), + file, + isLocal: true, })); - const updatedImages = [...value.uploaded, ...newImages].slice(0, maxImages); - - // Auto-assign first image as banner and second as card if not assigned - const newAssignments: ImageAssignments = { - uploaded: updatedImages, - banner_assignment: value.banner_assignment ?? (updatedImages.length > 0 ? 0 : undefined), - card_assignment: value.card_assignment ?? (updatedImages.length > 1 ? 1 : undefined) + const updatedUploaded = [...value.uploaded, ...newImages]; + const updatedValue: ImageAssignments = { + uploaded: updatedUploaded, + banner_assignment: value.banner_assignment, + card_assignment: value.card_assignment, }; - onChange(newAssignments); - setShowUploader(false); + // Auto-assign banner if not set + if (updatedValue.banner_assignment === undefined || updatedValue.banner_assignment === null) { + if (updatedUploaded.length > 0) { + updatedValue.banner_assignment = 0; + } + } + + // Auto-assign card if not set + if (updatedValue.card_assignment === undefined || updatedValue.card_assignment === null) { + if (updatedUploaded.length > 0) { + updatedValue.card_assignment = 0; + } + } + + onChange(updatedValue); + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } }; const handleAssignRole = (index: number, role: 'banner' | 'card') => { - onChange({ + const updatedValue: ImageAssignments = { ...value, - [role === 'banner' ? 'banner_assignment' : 'card_assignment']: index - }); + [role === 'banner' ? 'banner_assignment' : 'card_assignment']: index, + }; + onChange(updatedValue); }; const handleRemoveImage = (index: number) => { - const newUploaded = value.uploaded.filter((_, i) => i !== index); + const imageToRemove = value.uploaded[index]; - // Adjust assignments after removal - let newBannerAssignment = value.banner_assignment; - let newCardAssignment = value.card_assignment; - - if (value.banner_assignment === index) { - newBannerAssignment = undefined; - } else if (value.banner_assignment !== undefined && value.banner_assignment > index) { - newBannerAssignment = value.banner_assignment - 1; + // Revoke object URL if it's a local file + if (imageToRemove.isLocal && imageToRemove.url.startsWith('blob:')) { + URL.revokeObjectURL(imageToRemove.url); } - if (value.card_assignment === index) { - newCardAssignment = undefined; - } else if (value.card_assignment !== undefined && value.card_assignment > index) { - newCardAssignment = value.card_assignment - 1; - } - - onChange({ - uploaded: newUploaded, - banner_assignment: newBannerAssignment, - card_assignment: newCardAssignment - }); + const updatedUploaded = value.uploaded.filter((_, i) => i !== index); + + const updatedValue: ImageAssignments = { + uploaded: updatedUploaded, + banner_assignment: value.banner_assignment === index + ? (updatedUploaded.length > 0 ? 0 : null) + : value.banner_assignment !== null && value.banner_assignment !== undefined && value.banner_assignment > index + ? value.banner_assignment - 1 + : value.banner_assignment, + card_assignment: value.card_assignment === index + ? (updatedUploaded.length > 0 ? 0 : null) + : value.card_assignment !== null && value.card_assignment !== undefined && value.card_assignment > index + ? value.card_assignment - 1 + : value.card_assignment, + }; + + onChange(updatedValue); }; const renderImageCard = (image: UploadedImage, index: number) => { @@ -94,143 +128,188 @@ export function EntityMultiImageUploader({ const isCard = value.card_assignment === index; return ( - -
- {`Upload - - {/* Hover overlay with actions */} -
-
- - + + +
+
+ {`Upload +
+ + {/* Role badges - always visible */} +
+ {isBanner && ( + + + Banner + + )} + {isCard && ( + + + Card + + )} + {image.isLocal && ( + + Not uploaded + + )}
- -
- {/* Role badges */} -
- {isBanner && ( - - - Banner - - )} - {isCard && ( - - - Card - - )} + {/* Hover hint */} +
+

+ Right-click for options +

+
-
- + + + + handleAssignRole(index, 'banner')} + disabled={isBanner} + > + + {isBanner ? 'Banner (Current)' : 'Set as Banner'} + + + handleAssignRole(index, 'card')} + disabled={isCard} + > + + {isCard ? 'Card (Current)' : 'Set as Card'} + + + + + handleRemoveImage(index)} + className="text-destructive focus:text-destructive" + > + + Remove Image + + + ); }; + const getHelperText = () => { + if (value.uploaded.length === 0) { + return 'Upload images to get started. Images will be uploaded when you submit the form.'; + } + + const pendingUploads = value.uploaded.filter(img => img.isLocal).length; + const parts = []; + + if (pendingUploads > 0) { + parts.push(`${pendingUploads} image${pendingUploads > 1 ? 's' : ''} ready to upload on submission`); + } + + parts.push('Right-click any image to assign roles or remove'); + + return parts.join(' • '); + }; + return (
-
-
- - - {value.uploaded.length} / {maxImages} images - - {mode === 'create' && ( - - Upload up to 5, assign banner & card roles - - )} -
-
+ - {/* Upload section */} - {canUploadMore && ( -
- {!showUploader ? ( - - ) : ( - -
-
- - -
- -
-
- )} -
- )} - - {/* Image grid */} - {value.uploaded.length > 0 && ( -
- {value.uploaded.map((image, index) => renderImageCard(image, index))} -
- )} - - {/* Help text */} - {value.uploaded.length === 0 && ( -
- -

Upload images and assign which should be used as banner and card images

-
- )} + {value.uploaded.length > 0 && ( -
-

• Click images to assign as Banner (header) or Card (thumbnail)

-

• Banner and Card can be the same image or different images

+
+
+ {value.uploaded.map((image, index) => renderImageCard(image, index))} +
+ +
+

{getHelperText()}

+
+

+ Banner: Main header image for the {entityType} detail page + {value.banner_assignment !== null && value.banner_assignment !== undefined && ` (Image ${value.banner_assignment + 1})`} +

+

+ Card: Thumbnail in {entityType} listings and search results + {value.card_assignment !== null && value.card_assignment !== undefined && ` (Image ${value.card_assignment + 1})`} +

+
+
)}
); } + +// Helper function to upload all local files +export async function uploadPendingImages(images: UploadedImage[]): Promise { + const uploadedImages: UploadedImage[] = []; + + for (const image of images) { + if (image.isLocal && image.file) { + try { + // Get upload URL from Cloudflare + const { data: uploadData, error: uploadError } = await fetch('/api/upload-image', { + method: 'POST', + }).then(res => res.json()); + + if (uploadError) { + throw new Error('Failed to get upload URL'); + } + + // Upload to Cloudflare + const formData = new FormData(); + formData.append('file', image.file); + + const uploadResponse = await fetch(uploadData.uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to upload image'); + } + + const result = await uploadResponse.json(); + + // Return uploaded image with Cloudflare data + uploadedImages.push({ + url: result.result.variants[0], + cloudflare_id: result.result.id, + caption: image.caption, + }); + } catch (error) { + console.error('Error uploading image:', error); + throw error; + } + } else { + // Already uploaded, keep as is + uploadedImages.push(image); + } + } + + return uploadedImages; +} diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts new file mode 100644 index 00000000..21ad5342 --- /dev/null +++ b/src/lib/imageUploadHelper.ts @@ -0,0 +1,78 @@ +import { supabase } from '@/integrations/supabase/client'; +import type { UploadedImage } from '@/components/upload/EntityMultiImageUploader'; + +export interface CloudflareUploadResponse { + result: { + id: string; + variants: string[]; + }; + success: boolean; +} + +/** + * Uploads pending local images to Cloudflare via Supabase Edge Function + * @param images Array of UploadedImage objects (mix of local and already uploaded) + * @returns Array of UploadedImage objects with all images uploaded + */ +export async function uploadPendingImages(images: UploadedImage[]): Promise { + const uploadedImages: UploadedImage[] = []; + + for (const image of images) { + if (image.isLocal && image.file) { + try { + // Step 1: Get upload URL from our Supabase Edge Function + const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', { + body: { action: 'get-upload-url' } + }); + + if (urlError || !uploadUrlData?.uploadURL) { + console.error('Error getting upload URL:', urlError); + throw new Error('Failed to get upload URL from Cloudflare'); + } + + // Step 2: Upload file directly to Cloudflare + const formData = new FormData(); + formData.append('file', image.file); + + const uploadResponse = await fetch(uploadUrlData.uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status: ${uploadResponse.status}`); + } + + const result: CloudflareUploadResponse = await uploadResponse.json(); + + if (!result.success || !result.result) { + throw new Error('Cloudflare upload returned unsuccessful response'); + } + + // Step 3: Return uploaded image metadata + uploadedImages.push({ + url: result.result.variants[0], // Use first variant (usually the original) + cloudflare_id: result.result.id, + caption: image.caption, + isLocal: false, + }); + + // Clean up object URL + URL.revokeObjectURL(image.url); + } catch (error) { + console.error('Error uploading image:', error); + throw new Error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } else { + // Already uploaded, keep as is + uploadedImages.push({ + url: image.url, + cloudflare_id: image.cloudflare_id, + caption: image.caption, + isLocal: false, + }); + } + } + + return uploadedImages; +}