mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:11:12 -05:00
feat: Implement enhanced multi-image upload
This commit is contained in:
236
src/components/upload/EntityMultiImageUploader.tsx
Normal file
236
src/components/upload/EntityMultiImageUploader.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ImageIcon, Star, CreditCard, X } from 'lucide-react';
|
||||
import { UppyPhotoUpload } from './UppyPhotoUpload';
|
||||
|
||||
export interface UploadedImage {
|
||||
url: string;
|
||||
cloudflare_id: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface ImageAssignments {
|
||||
uploaded: UploadedImage[];
|
||||
banner_assignment?: number;
|
||||
card_assignment?: number;
|
||||
}
|
||||
|
||||
interface EntityMultiImageUploaderProps {
|
||||
mode: 'create' | 'edit';
|
||||
value: ImageAssignments;
|
||||
onChange: (assignments: ImageAssignments) => void;
|
||||
entityId?: string;
|
||||
entityType?: string;
|
||||
}
|
||||
|
||||
export function EntityMultiImageUploader({
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
entityType = 'entity'
|
||||
}: EntityMultiImageUploaderProps) {
|
||||
const [showUploader, setShowUploader] = useState(false);
|
||||
|
||||
const maxImages = mode === 'create' ? 5 : 3;
|
||||
const canUploadMore = value.uploaded.length < maxImages;
|
||||
|
||||
const handleUploadComplete = (urls: string[]) => {
|
||||
const newImages: UploadedImage[] = urls.map(url => ({
|
||||
url,
|
||||
cloudflare_id: url.split('/').pop()?.split('?')[0] || ''
|
||||
}));
|
||||
|
||||
const updatedImages = [...value.uploaded, ...newImages].slice(0, maxImages);
|
||||
|
||||
// Auto-assign first image as banner and second as card if not assigned
|
||||
const newAssignments: ImageAssignments = {
|
||||
uploaded: updatedImages,
|
||||
banner_assignment: value.banner_assignment ?? (updatedImages.length > 0 ? 0 : undefined),
|
||||
card_assignment: value.card_assignment ?? (updatedImages.length > 1 ? 1 : undefined)
|
||||
};
|
||||
|
||||
onChange(newAssignments);
|
||||
setShowUploader(false);
|
||||
};
|
||||
|
||||
const handleAssignRole = (index: number, role: 'banner' | 'card') => {
|
||||
onChange({
|
||||
...value,
|
||||
[role === 'banner' ? 'banner_assignment' : 'card_assignment']: index
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const newUploaded = value.uploaded.filter((_, i) => i !== index);
|
||||
|
||||
// Adjust assignments after removal
|
||||
let newBannerAssignment = value.banner_assignment;
|
||||
let newCardAssignment = value.card_assignment;
|
||||
|
||||
if (value.banner_assignment === index) {
|
||||
newBannerAssignment = undefined;
|
||||
} else if (value.banner_assignment !== undefined && value.banner_assignment > index) {
|
||||
newBannerAssignment = value.banner_assignment - 1;
|
||||
}
|
||||
|
||||
if (value.card_assignment === index) {
|
||||
newCardAssignment = undefined;
|
||||
} else if (value.card_assignment !== undefined && value.card_assignment > index) {
|
||||
newCardAssignment = value.card_assignment - 1;
|
||||
}
|
||||
|
||||
onChange({
|
||||
uploaded: newUploaded,
|
||||
banner_assignment: newBannerAssignment,
|
||||
card_assignment: newCardAssignment
|
||||
});
|
||||
};
|
||||
|
||||
const renderImageCard = (image: UploadedImage, index: number) => {
|
||||
const isBanner = value.banner_assignment === index;
|
||||
const isCard = value.card_assignment === index;
|
||||
|
||||
return (
|
||||
<Card key={index} className="overflow-hidden relative group">
|
||||
<div className="relative aspect-[16/9] bg-muted">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Upload ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Hover overlay with actions */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 p-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isBanner ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => handleAssignRole(index, 'banner')}
|
||||
>
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{isBanner ? 'Banner ✓' : 'Set Banner'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isCard ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => handleAssignRole(index, 'card')}
|
||||
>
|
||||
<CreditCard className="w-3 h-3 mr-1" />
|
||||
{isCard ? 'Card ✓' : 'Set Card'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role badges */}
|
||||
<div className="absolute top-2 left-2 flex gap-1">
|
||||
{isBanner && (
|
||||
<Badge className="bg-primary text-primary-foreground">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Banner
|
||||
</Badge>
|
||||
)}
|
||||
{isCard && (
|
||||
<Badge className="bg-secondary text-secondary-foreground">
|
||||
<CreditCard className="w-3 h-3 mr-1" />
|
||||
Card
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base">Images</Label>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{value.uploaded.length} / {maxImages} images
|
||||
</Badge>
|
||||
{mode === 'create' && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Upload up to 5, assign banner & card roles
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload section */}
|
||||
{canUploadMore && (
|
||||
<div>
|
||||
{!showUploader ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowUploader(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
{value.uploaded.length === 0 ? 'Upload Images' : 'Upload More Images'}
|
||||
</Button>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">Upload Images</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUploader(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UppyPhotoUpload
|
||||
onUploadComplete={handleUploadComplete}
|
||||
maxFiles={maxImages - value.uploaded.length}
|
||||
variant="compact"
|
||||
allowedFileTypes={['image/jpeg', 'image/jpg', 'image/png', 'image/webp']}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image grid */}
|
||||
{value.uploaded.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{value.uploaded.map((image, index) => renderImageCard(image, index))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{value.uploaded.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Upload images and assign which should be used as banner and card images</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.uploaded.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>• Click images to assign as Banner (header) or Card (thumbnail)</p>
|
||||
<p>• Banner and Card can be the same image or different images</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user