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

@@ -45,7 +45,11 @@ type DesignerFormData = z.infer<typeof designerSchema>;
interface DesignerFormProps { interface DesignerFormProps {
onSubmit: (data: DesignerFormData) => void; onSubmit: (data: DesignerFormData) => void;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<DesignerFormData>; initialData?: Partial<DesignerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
} }
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) { export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) {
@@ -193,6 +197,9 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)} onChange={(images) => setValue('images', images)}
entityType="designer" entityType="designer"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Actions */} {/* Actions */}

View File

@@ -45,7 +45,11 @@ type ManufacturerFormData = z.infer<typeof manufacturerSchema>;
interface ManufacturerFormProps { interface ManufacturerFormProps {
onSubmit: (data: ManufacturerFormData) => void; onSubmit: (data: ManufacturerFormData) => void;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<ManufacturerFormData>; initialData?: Partial<ManufacturerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
} }
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) { export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) {
@@ -193,6 +197,9 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)} onChange={(images) => setValue('images', images)}
entityType="manufacturer" entityType="manufacturer"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Actions */} {/* Actions */}

View File

@@ -45,7 +45,11 @@ type OperatorFormData = z.infer<typeof operatorSchema>;
interface OperatorFormProps { interface OperatorFormProps {
onSubmit: (data: OperatorFormData) => void; onSubmit: (data: OperatorFormData) => void;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<OperatorFormData>; initialData?: Partial<OperatorFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
} }
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) { export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) {
@@ -193,6 +197,9 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)} onChange={(images) => setValue('images', images)}
entityType="operator" entityType="operator"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Actions */} {/* Actions */}

View File

@@ -55,8 +55,11 @@ interface ParkFormProps {
}) => Promise<void>; }) => Promise<void>;
onCancel?: () => void; onCancel?: () => void;
initialData?: Partial<ParkFormData & { initialData?: Partial<ParkFormData & {
id?: string;
operator_id?: string; operator_id?: string;
property_owner_id?: string; property_owner_id?: string;
banner_image_url?: string;
card_image_url?: string;
}>; }>;
isEditing?: boolean; isEditing?: boolean;
} }
@@ -421,6 +424,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
value={watch('images')} value={watch('images')}
onChange={(images: ImageAssignments) => setValue('images', images)} onChange={(images: ImageAssignments) => setValue('images', images)}
entityType="park" entityType="park"
entityId={isEditing ? initialData?.id : undefined}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Form Actions */} {/* Form Actions */}

View File

@@ -45,7 +45,11 @@ type PropertyOwnerFormData = z.infer<typeof propertyOwnerSchema>;
interface PropertyOwnerFormProps { interface PropertyOwnerFormProps {
onSubmit: (data: PropertyOwnerFormData) => void; onSubmit: (data: PropertyOwnerFormData) => void;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<PropertyOwnerFormData>; initialData?: Partial<PropertyOwnerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
} }
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) { export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) {
@@ -193,6 +197,9 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)} onChange={(images) => setValue('images', images)}
entityType="property_owner" entityType="property_owner"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Actions */} {/* Actions */}

View File

@@ -84,7 +84,11 @@ type RideFormData = z.infer<typeof rideSchema>;
interface RideFormProps { interface RideFormProps {
onSubmit: (data: RideFormData) => Promise<void>; onSubmit: (data: RideFormData) => Promise<void>;
onCancel?: () => void; onCancel?: () => void;
initialData?: Partial<RideFormData>; initialData?: Partial<RideFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
isEditing?: boolean; isEditing?: boolean;
} }
@@ -752,6 +756,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images: ImageAssignments) => setValue('images', images)} onChange={(images: ImageAssignments) => setValue('images', images)}
entityType="ride" entityType="ride"
entityId={isEditing ? initialData?.id : undefined}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Form Actions */} {/* Form Actions */}

View File

@@ -42,7 +42,11 @@ interface RideModelFormProps {
manufacturerId?: string; manufacturerId?: string;
onSubmit: (data: RideModelFormData) => void; onSubmit: (data: RideModelFormData) => void;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<RideModelFormData>; initialData?: Partial<RideModelFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
} }
const categories = [ const categories = [
@@ -193,6 +197,9 @@ export function RideModelForm({
value={watch('images') || { uploaded: [] }} value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)} onChange={(images) => setValue('images', images)}
entityType="ride_model" entityType="ride_model"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/> />
{/* Actions */} {/* Actions */}

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 { Button } from '@/components/ui/button';
import { Upload, Star, CreditCard, Trash2, ImagePlus } from 'lucide-react'; import { Upload, Star, CreditCard, Trash2, ImagePlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -10,6 +10,9 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu'; } from '@/components/ui/context-menu';
import { DragDropZone } from './DragDropZone'; 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 { export interface UploadedImage {
url: string; url: string;
@@ -31,15 +34,70 @@ interface EntityMultiImageUploaderProps {
onChange: (assignments: ImageAssignments) => void; onChange: (assignments: ImageAssignments) => void;
entityId?: string; entityId?: string;
entityType?: string; entityType?: string;
currentBannerUrl?: string;
currentCardUrl?: string;
} }
export function EntityMultiImageUploader({ export function EntityMultiImageUploader({
mode, mode,
value, value,
onChange, onChange,
entityType = 'entity' entityType = 'entity',
entityId,
currentBannerUrl,
currentCardUrl
}: EntityMultiImageUploaderProps) { }: EntityMultiImageUploaderProps) {
const maxImages = mode === 'create' ? 5 : 3; 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 handleFilesAdded = (files: File[]) => {
const currentCount = value.uploaded.length; const currentCount = value.uploaded.length;
@@ -118,12 +176,15 @@ export function EntityMultiImageUploader({
const renderImageCard = (image: UploadedImage, index: number) => { const renderImageCard = (image: UploadedImage, index: number) => {
const isBanner = value.banner_assignment === index; const isBanner = value.banner_assignment === index;
const isCard = value.card_assignment === index; const isCard = value.card_assignment === index;
const isExisting = !image.isLocal;
return ( return (
<ContextMenu key={index}> <ContextMenu key={index}>
<ContextMenuTrigger> <ContextMenuTrigger>
<div className="relative group cursor-context-menu"> <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 <img
src={image.url} src={image.url}
alt={`Upload ${index + 1}`} alt={`Upload ${index + 1}`}
@@ -145,6 +206,11 @@ export function EntityMultiImageUploader({
Card Card
</Badge> </Badge>
)} )}
{isExisting && (
<Badge variant="outline" className="bg-blue-100 dark:bg-blue-900">
Existing
</Badge>
)}
{image.isLocal && ( {image.isLocal && (
<Badge variant="outline" className="bg-background/80"> <Badge variant="outline" className="bg-background/80">
Not uploaded Not uploaded
@@ -152,6 +218,21 @@ export function EntityMultiImageUploader({
)} )}
</div> </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 */} {/* Hover hint */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-lg flex items-center justify-center"> <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"> <p className="text-background text-xs opacity-0 group-hover:opacity-100 transition-opacity font-medium">
@@ -178,8 +259,9 @@ export function EntityMultiImageUploader({
{isCard ? 'Card (Current)' : 'Set as Card'} {isCard ? 'Card (Current)' : 'Set as Card'}
</ContextMenuItem> </ContextMenuItem>
{image.isLocal && (
<>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
onClick={() => handleRemoveImage(index)} onClick={() => handleRemoveImage(index)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
@@ -187,6 +269,8 @@ export function EntityMultiImageUploader({
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Remove Image Remove Image
</ContextMenuItem> </ContextMenuItem>
</>
)}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
@@ -197,18 +281,37 @@ export function EntityMultiImageUploader({
return 'Upload images to get started. Images will be uploaded when you submit the form.'; 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 = []; const parts = [];
if (pendingUploads > 0) { if (mode === 'edit' && existingCount > 0) {
parts.push(`${pendingUploads} image${pendingUploads > 1 ? 's' : ''} ready to upload on submission`); 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(' • '); 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 // Empty state: show large drag & drop zone
if (value.uploaded.length === 0) { if (value.uploaded.length === 0) {
return ( return (
@@ -222,6 +325,7 @@ export function EntityMultiImageUploader({
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<p> Right-click images to set as banner or card</p> <p> Right-click images to set as banner or card</p>
<p> Images will be uploaded when you submit the form</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>
</div> </div>
); );