feat: Implement photo selection for entity edit forms

This commit is contained in:
gpt-engineer-app[bot]
2025-10-02 14:28:57 +00:00
parent 0a87a72931
commit fddb87c5be
8 changed files with 174 additions and 22 deletions

View File

@@ -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>
);