mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 06:31:13 -05:00
feat: Implement photo selection for entity edit forms
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,15 +259,18 @@ export function EntityMultiImageUploader({
|
|||||||
{isCard ? 'Card (Current)' : 'Set as Card'}
|
{isCard ? 'Card (Current)' : 'Set as Card'}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
{image.isLocal && (
|
||||||
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuSeparator />
|
||||||
onClick={() => handleRemoveImage(index)}
|
<ContextMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
onClick={() => handleRemoveImage(index)}
|
||||||
>
|
className="text-destructive focus:text-destructive"
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
>
|
||||||
Remove Image
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
</ContextMenuItem>
|
Remove Image
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user