mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 12:51:14 -05:00
feat: Implement photo selection for entity edit forms
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
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';
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
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;
|
||||
@@ -31,15 +34,70 @@ interface EntityMultiImageUploaderProps {
|
||||
onChange: (assignments: ImageAssignments) => void;
|
||||
entityId?: string;
|
||||
entityType?: string;
|
||||
currentBannerUrl?: string;
|
||||
currentCardUrl?: string;
|
||||
}
|
||||
|
||||
export function EntityMultiImageUploader({
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
entityType = 'entity'
|
||||
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]);
|
||||
|
||||
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;
|
||||
@@ -118,12 +176,15 @@ export function EntityMultiImageUploader({
|
||||
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 border-border bg-muted">
|
||||
<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}`}
|
||||
@@ -145,6 +206,11 @@ export function EntityMultiImageUploader({
|
||||
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
|
||||
@@ -152,6 +218,21 @@ export function EntityMultiImageUploader({
|
||||
)}
|
||||
</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">
|
||||
@@ -178,15 +259,18 @@ export function EntityMultiImageUploader({
|
||||
{isCard ? 'Card (Current)' : 'Set as Card'}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove Image
|
||||
</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>
|
||||
);
|
||||
@@ -197,18 +281,37 @@ export function EntityMultiImageUploader({
|
||||
return 'Upload images to get started. Images will be uploaded when you submit the form.';
|
||||
}
|
||||
|
||||
const pendingUploads = value.uploaded.filter(img => img.isLocal).length;
|
||||
const existingCount = value.uploaded.filter(img => !img.isLocal).length;
|
||||
const newCount = value.uploaded.filter(img => img.isLocal).length;
|
||||
const parts = [];
|
||||
|
||||
if (pendingUploads > 0) {
|
||||
parts.push(`${pendingUploads} image${pendingUploads > 1 ? 's' : ''} ready to upload on submission`);
|
||||
if (mode === 'edit' && existingCount > 0) {
|
||||
parts.push(`${existingCount} existing photo${existingCount !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
parts.push('Right-click any image to assign roles or remove');
|
||||
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 (
|
||||
@@ -222,6 +325,7 @@ export function EntityMultiImageUploader({
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user