mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
397 lines
13 KiB
TypeScript
397 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';
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|