import React, { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Upload, Star, CreditCard, Trash2, ImagePlus } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { DragDropZone } from './DragDropZone'; import { supabase } from '@/integrations/supabase/client'; import { toast } from '@/hooks/use-toast'; import { Skeleton } from '@/components/ui/skeleton'; import { getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; export interface UploadedImage { url: string; cloudflare_id?: string; file?: File; isLocal?: boolean; caption?: string; } export interface ImageAssignments { uploaded: UploadedImage[]; banner_assignment?: number | null; card_assignment?: number | null; } interface EntityMultiImageUploaderProps { mode: 'create' | 'edit'; value: ImageAssignments; onChange: (assignments: ImageAssignments) => void; entityId?: string; entityType?: string; currentBannerUrl?: string; currentCardUrl?: string; } export function EntityMultiImageUploader({ mode, value, onChange, entityType = 'entity', entityId, currentBannerUrl, currentCardUrl }: EntityMultiImageUploaderProps) { const maxImages = mode === 'create' ? 5 : 3; const [loadingPhotos, setLoadingPhotos] = useState(false); // Fetch existing photos when in edit mode useEffect(() => { if (mode === 'edit' && entityId && entityType) { fetchEntityPhotos(); } }, [mode, entityId, entityType]); // Cleanup blob URLs when component unmounts or images change useEffect(() => { const currentImages = value.uploaded; return () => { // Revoke all blob URLs on cleanup currentImages.forEach(image => { if (image.isLocal && image.url.startsWith('blob:')) { try { URL.revokeObjectURL(image.url); } catch (error: unknown) { logger.error('Failed to revoke object URL', { error: getErrorMessage(error) }); } } }); }; }, [value.uploaded]); const fetchEntityPhotos = async () => { setLoadingPhotos(true); try { const { data, error } = await supabase .from('photos') .select('id, cloudflare_image_url, cloudflare_image_id, caption, title') .eq('entity_type', entityType) .eq('entity_id', entityId) .order('created_at', { ascending: false }); if (error) throw error; // Map to UploadedImage format const mappedPhotos: UploadedImage[] = data?.map(photo => ({ url: photo.cloudflare_image_url, cloudflare_id: photo.cloudflare_image_id, caption: photo.caption || photo.title || '', isLocal: false, })) || []; // Find banner and card indices based on currentBannerUrl/currentCardUrl const bannerIndex = mappedPhotos.findIndex(p => p.url === currentBannerUrl); const cardIndex = mappedPhotos.findIndex(p => p.url === currentCardUrl); // Initialize with existing photos and current assignments onChange({ uploaded: mappedPhotos, banner_assignment: bannerIndex >= 0 ? bannerIndex : null, card_assignment: cardIndex >= 0 ? cardIndex : null, }); } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); } finally { setLoadingPhotos(false); } }; const handleFilesAdded = (files: File[]) => { const currentCount = value.uploaded.length; const availableSlots = maxImages - currentCount; if (availableSlots <= 0) { return; } const filesToAdd = files.slice(0, availableSlots); const newImages: UploadedImage[] = filesToAdd.map(file => ({ url: URL.createObjectURL(file), file, isLocal: true, })); const updatedUploaded = [...value.uploaded, ...newImages]; const updatedValue: ImageAssignments = { uploaded: updatedUploaded, banner_assignment: value.banner_assignment, card_assignment: value.card_assignment, }; // 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); }; const handleAssignRole = (index: number, role: 'banner' | 'card') => { const updatedValue: ImageAssignments = { ...value, [role === 'banner' ? 'banner_assignment' : 'card_assignment']: index, }; onChange(updatedValue); }; const handleRemoveImage = (index: number) => { const imageToRemove = value.uploaded[index]; // Revoke object URL if it's a local file if (imageToRemove.isLocal && imageToRemove.url.startsWith('blob:')) { URL.revokeObjectURL(imageToRemove.url); } 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) => { const isBanner = value.banner_assignment === index; const isCard = value.card_assignment === index; const isExisting = !image.isLocal; return (
{`Upload
{/* Role badges - always visible */}
{isBanner && ( Banner )} {isCard && ( Card )} {isExisting && ( Existing )} {image.isLocal && ( Not uploaded )}
{/* Remove button - only for new uploads */} {image.isLocal && ( )} {/* 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'} {image.isLocal && ( <> 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 existingCount = value.uploaded.filter(img => !img.isLocal).length; const newCount = value.uploaded.filter(img => img.isLocal).length; const parts = []; if (mode === 'edit' && existingCount > 0) { parts.push(`${existingCount} existing photo${existingCount !== 1 ? 's' : ''}`); } if (newCount > 0) { parts.push(`${newCount} new image${newCount > 1 ? 's' : ''} ready to upload`); } parts.push('Right-click to assign banner/card roles'); return parts.join(' • '); }; // Loading state if (loadingPhotos) { return (
{[1, 2, 3].map((i) => ( ))}

Loading existing photos...

); } // Empty state: show large drag & drop zone if (value.uploaded.length === 0) { return (

• Right-click images to set as banner or card

• Images will be uploaded when you submit the form

{mode === 'edit' &&

• No existing photos found for this entity

}
); } // With images: show grid + compact upload area return (
{/* Image Grid */}
{value.uploaded.map((image, index) => renderImageCard(image, index))}
{/* Compact Upload Area */} {value.uploaded.length < maxImages && (

Add More Images

Drag & drop or click to browse ({value.uploaded.length}/{maxImages})

)} {/* Helper Text */}

{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})`}

); }