Files
thrilltrack-explorer/src/components/upload/EntityMultiImageUploader.tsx
pac7 bfba3baf7e Improve component stability and user experience with safety checks
Implement robust error handling, safety checks for data structures, and state management improvements across various components to prevent runtime errors and enhance user experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: a71e826a-1d38-4b6e-a34f-fbf5ba1f1b25
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
2025-10-08 19:27:31 +00:00

396 lines
13 KiB
TypeScript

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';
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) {
console.error('Error revoking object URL:', 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) {
console.error('Failed to load entity photos:', error);
toast({
title: 'Error',
description: 'Failed to load existing photos',
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 (
<ContextMenu key={index}>
<ContextMenuTrigger>
<div className="relative group cursor-context-menu">
<div className={`aspect-[4/3] rounded-lg overflow-hidden border-2 ${
isExisting ? 'border-blue-400' : 'border-border'
} bg-muted`}>
<img
src={image.url}
alt={`Upload ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
{/* Role badges - always visible */}
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
{isBanner && (
<Badge variant="default" className="gap-1">
<Star className="h-3 w-3" />
Banner
</Badge>
)}
{isCard && (
<Badge variant="secondary" className="gap-1">
<CreditCard className="h-3 w-3" />
Card
</Badge>
)}
{isExisting && (
<Badge variant="outline" className="bg-blue-100 dark:bg-blue-900">
Existing
</Badge>
)}
{image.isLocal && (
<Badge variant="outline" className="bg-background/80">
Not uploaded
</Badge>
)}
</div>
{/* Remove button - only for new uploads */}
{image.isLocal && (
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(index);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
{/* 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>
</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>
{image.isLocal && (
<>
<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 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 (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="aspect-[4/3] rounded-lg" />
))}
</div>
<p className="text-sm text-muted-foreground">Loading existing photos...</p>
</div>
);
}
// Empty state: show large drag & drop zone
if (value.uploaded.length === 0) {
return (
<div className="space-y-4">
<DragDropZone
onFilesAdded={handleFilesAdded}
maxFiles={maxImages}
maxSizeMB={25}
allowedFileTypes={['image/*']}
/>
<div className="text-sm text-muted-foreground space-y-1">
<p> Right-click images to set as banner or card</p>
<p> Images will be uploaded when you submit the form</p>
{mode === 'edit' && <p> No existing photos found for this entity</p>}
</div>
</div>
);
}
// With images: show grid + compact upload area
return (
<div className="space-y-4">
{/* Image Grid */}
<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>
{/* Compact Upload Area */}
{value.uploaded.length < maxImages && (
<DragDropZone
onFilesAdded={handleFilesAdded}
maxFiles={maxImages - value.uploaded.length}
maxSizeMB={25}
allowedFileTypes={['image/*']}
className="p-6"
>
<div className="text-center space-y-2">
<ImagePlus className="w-8 h-8 mx-auto text-muted-foreground" />
<p className="text-sm font-medium">Add More Images</p>
<p className="text-xs text-muted-foreground">
Drag & drop or click to browse ({value.uploaded.length}/{maxImages})
</p>
</div>
</DragDropZone>
)}
{/* Helper Text */}
<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>
</div>
);
}