mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:31:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
425
src-old/components/upload/EntityMultiImageUploader.tsx
Normal file
425
src-old/components/upload/EntityMultiImageUploader.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user