feat: Enhance image uploader with context menu and deferred upload

This commit is contained in:
gpt-engineer-app[bot]
2025-10-01 19:21:50 +00:00
parent 83260e7f73
commit c582e6fc1b
9 changed files with 364 additions and 193 deletions

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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()
});

View File

@@ -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<HTMLInputElement>(null);
const handleUploadComplete = (urls: string[]) => {
const newImages: UploadedImage[] = urls.map(url => ({
url,
cloudflare_id: url.split('/').pop()?.split('?')[0] || ''
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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;
}
const updatedUploaded = value.uploaded.filter((_, i) => i !== index);
onChange({
uploaded: newUploaded,
banner_assignment: newBannerAssignment,
card_assignment: newCardAssignment
});
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 (
<Card key={index} className="overflow-hidden relative group">
<div className="relative aspect-[16/9] bg-muted">
<ContextMenu key={index}>
<ContextMenuTrigger>
<div className="relative group cursor-context-menu">
<div className="aspect-[4/3] rounded-lg overflow-hidden border-2 border-border bg-muted">
<img
src={image.url}
alt={`Upload ${index + 1}`}
className="w-full h-full object-cover"
/>
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 p-2">
<div className="flex gap-2">
<Button
type="button"
variant={isBanner ? "default" : "secondary"}
size="sm"
onClick={() => handleAssignRole(index, 'banner')}
>
<Star className="w-3 h-3 mr-1" />
{isBanner ? 'Banner ✓' : 'Set Banner'}
</Button>
<Button
type="button"
variant={isCard ? "default" : "secondary"}
size="sm"
onClick={() => handleAssignRole(index, 'card')}
>
<CreditCard className="w-3 h-3 mr-1" />
{isCard ? 'Card ✓' : 'Set Card'}
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => handleRemoveImage(index)}
>
<X className="w-3 h-3 mr-1" />
Remove
</Button>
</div>
{/* Role badges */}
<div className="absolute top-2 left-2 flex gap-1">
{/* Role badges - always visible */}
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
{isBanner && (
<Badge className="bg-primary text-primary-foreground">
<Star className="w-3 h-3 mr-1" />
<Badge variant="default" className="gap-1">
<Star className="h-3 w-3" />
Banner
</Badge>
)}
{isCard && (
<Badge className="bg-secondary text-secondary-foreground">
<CreditCard className="w-3 h-3 mr-1" />
<Badge variant="secondary" className="gap-1">
<CreditCard className="h-3 w-3" />
Card
</Badge>
)}
{image.isLocal && (
<Badge variant="outline" className="bg-background/80">
Not uploaded
</Badge>
)}
</div>
{/* Hover hint */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-lg flex items-center justify-center">
<p className="text-background text-xs opacity-0 group-hover:opacity-100 transition-opacity font-medium">
Right-click for options
</p>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => handleAssignRole(index, 'banner')}
disabled={isBanner}
>
<Star className="mr-2 h-4 w-4" />
{isBanner ? 'Banner (Current)' : 'Set as Banner'}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleAssignRole(index, 'card')}
disabled={isCard}
>
<CreditCard className="mr-2 h-4 w-4" />
{isCard ? 'Card (Current)' : 'Set as Card'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => handleRemoveImage(index)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Remove Image
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-base">Images</Label>
<Badge variant="secondary" className="text-xs">
{value.uploaded.length} / {maxImages} images
</Badge>
{mode === 'create' && (
<Badge variant="outline" className="text-xs">
Upload up to 5, assign banner & card roles
</Badge>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple={mode === 'create'}
onChange={handleFileSelect}
className="hidden"
/>
{/* Upload section */}
{canUploadMore && (
<div>
{!showUploader ? (
<Button
type="button"
variant="outline"
onClick={() => setShowUploader(true)}
className="w-full"
onClick={() => fileInputRef.current?.click()}
disabled={value.uploaded.length >= maxImages}
>
<ImageIcon className="w-4 h-4 mr-2" />
{value.uploaded.length === 0 ? 'Upload Images' : 'Upload More Images'}
<Upload className="mr-2 h-4 w-4" />
Select {mode === 'create' ? 'Images' : 'Image'}
{` (${value.uploaded.length}/${maxImages})`}
</Button>
) : (
<Card className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">Upload Images</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUploader(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<UppyPhotoUpload
onUploadComplete={handleUploadComplete}
maxFiles={maxImages - value.uploaded.length}
variant="compact"
allowedFileTypes={['image/*']}
/>
</div>
</Card>
)}
</div>
)}
{/* Image grid */}
{value.uploaded.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{value.uploaded.map((image, index) => renderImageCard(image, index))}
</div>
)}
{/* Help text */}
{value.uploaded.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Upload images and assign which should be used as banner and card images</p>
<div className="space-y-2 text-sm text-muted-foreground">
<p className="font-medium">{getHelperText()}</p>
<div className="space-y-1 pl-4 border-l-2 border-border">
<p>
<strong>Banner:</strong> Main header image for the {entityType} detail page
{value.banner_assignment !== null && value.banner_assignment !== undefined && ` (Image ${value.banner_assignment + 1})`}
</p>
<p>
<strong>Card:</strong> Thumbnail in {entityType} listings and search results
{value.card_assignment !== null && value.card_assignment !== undefined && ` (Image ${value.card_assignment + 1})`}
</p>
</div>
</div>
)}
{value.uploaded.length > 0 && (
<div className="text-sm text-muted-foreground">
<p> Click images to assign as Banner (header) or Card (thumbnail)</p>
<p> Banner and Card can be the same image or different images</p>
</div>
)}
</div>
);
}
// Helper function to upload all local files
export async function uploadPendingImages(images: UploadedImage[]): Promise<UploadedImage[]> {
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;
}

View File

@@ -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<UploadedImage[]> {
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;
}