Files
thrilltrack-explorer/src-old/components/upload/EntityMultiImageUploader.tsx

426 lines
14 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 '@/lib/supabaseClient';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { getErrorMessage } from '@/lib/errorHandler';
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 : 0; // No uploads allowed in edit mode
const canUpload = mode === 'create';
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 {
// Silent cleanup failure - non-critical
}
}
});
};
}, [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[]) => {
// Block uploads entirely in edit mode
if (!canUpload) {
toast({
title: 'Upload Not Allowed',
description: 'Photos cannot be added during edits. Use the photo gallery to submit additional photos.',
variant: 'destructive',
});
return;
}
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: string[] = [];
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 (create only) or message (edit)
if (value.uploaded.length === 0) {
if (mode === 'edit') {
return (
<Alert>
<AlertDescription>
No existing photos found. Photos can only be added during entity creation. Use the photo gallery to submit additional photos after creation.
</AlertDescription>
</Alert>
);
}
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>
</div>
</div>
);
}
// With images: show grid + compact upload area (create only) or read-only (edit)
return (
<div className="space-y-4">
{/* Edit mode notice */}
{mode === 'edit' && (
<Alert>
<AlertDescription>
Photos cannot be added during edits. You can reassign banner/card roles below. Use the photo gallery to submit additional photos.
</AlertDescription>
</Alert>
)}
{/* 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 - Create mode only */}
{canUpload && 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>
);
}