Fix: Resolve image upload and form integration issues

This commit is contained in:
gpt-engineer-app[bot]
2025-10-01 19:02:21 +00:00
parent 37b70111c6
commit 83260e7f73
6 changed files with 57 additions and 199 deletions

View File

@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { SlugField } from '@/components/ui/slug-field'; import { SlugField } from '@/components/ui/slug-field';
@@ -31,27 +31,28 @@ const parkSchema = z.object({
phone: z.string().optional(), phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')),
operator_id: z.string().uuid().optional(), operator_id: z.string().uuid().optional(),
property_owner_id: z.string().uuid().optional() property_owner_id: z.string().uuid().optional(),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string(),
caption: z.string().optional(),
})),
banner_assignment: z.number().optional(),
card_assignment: z.number().optional(),
}).optional()
}); });
type ParkFormData = z.infer<typeof parkSchema>; type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps { interface ParkFormProps {
onSubmit: (data: ParkFormData & { onSubmit: (data: ParkFormData & {
banner_image_url?: string;
card_image_url?: string;
banner_image_id?: string;
card_image_id?: string;
operator_id?: string; operator_id?: string;
property_owner_id?: string; property_owner_id?: string;
_compositeSubmission?: any; _compositeSubmission?: any;
}) => Promise<void>; }) => Promise<void>;
onCancel?: () => void; onCancel?: () => void;
initialData?: Partial<ParkFormData & { initialData?: Partial<ParkFormData & {
banner_image_url?: string;
card_image_url?: string;
banner_image_id?: string;
card_image_id?: string;
operator_id?: string; operator_id?: string;
property_owner_id?: string; property_owner_id?: string;
}>; }>;
@@ -81,10 +82,6 @@ const statusOptions = [
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) { export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || '');
const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || '');
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
const [cardImageId, setCardImageId] = useState<string>(initialData?.card_image_id || '');
// Operator state // Operator state
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || ''); const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
@@ -100,12 +97,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
const { operators, loading: operatorsLoading } = useOperators(); const { operators, loading: operatorsLoading } = useOperators();
const { propertyOwners, loading: ownersLoading } = usePropertyOwners(); const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
// Extract Cloudflare image ID from URL
const extractImageId = (url: string): string => {
const match = url.match(/\/([a-f0-9-]{36})\//);
return match ? match[1] : '';
};
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -126,7 +117,8 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
phone: initialData?.phone || '', phone: initialData?.phone || '',
email: initialData?.email || '', email: initialData?.email || '',
operator_id: initialData?.operator_id || undefined, operator_id: initialData?.operator_id || undefined,
property_owner_id: initialData?.property_owner_id || undefined property_owner_id: initialData?.property_owner_id || undefined,
images: { uploaded: [] }
} }
}); });
@@ -136,13 +128,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
try { try {
// Build composite submission if new entities were created // Build composite submission if new entities were created
const submissionContent: any = { const submissionContent: any = {
park: { park: data,
...data,
banner_image_url: bannerImage || undefined,
card_image_url: cardImage || undefined,
banner_image_id: bannerImageId || undefined,
card_image_id: cardImageId || undefined
},
}; };
// Add new operator if created // Add new operator if created
@@ -159,10 +145,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
await onSubmit({ await onSubmit({
...data, ...data,
banner_image_url: bannerImage || undefined,
card_image_url: cardImage || undefined,
banner_image_id: bannerImageId || undefined,
card_image_id: cardImageId || undefined,
operator_id: tempNewOperator ? undefined : (selectedOperatorId || undefined), operator_id: tempNewOperator ? undefined : (selectedOperatorId || undefined),
property_owner_id: tempNewPropertyOwner ? undefined : (selectedPropertyOwnerId || undefined), property_owner_id: tempNewPropertyOwner ? undefined : (selectedPropertyOwnerId || undefined),
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined _compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
@@ -432,73 +414,12 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
</div> </div>
{/* Images */} {/* Images */}
<div className="space-y-6"> <EntityMultiImageUploader
<div className="space-y-2"> mode={isEditing ? 'edit' : 'create'}
<Label>Banner Image</Label> value={watch('images')}
{bannerImage && ( onChange={(images: ImageAssignments) => setValue('images', images)}
<div className="mb-2"> entityType="park"
<img src={bannerImage} alt="Current banner" className="h-32 w-full object-cover rounded-md" />
</div>
)}
<UppyPhotoUpload
maxFiles={1}
variant="public"
onUploadComplete={(urls) => {
const url = urls[0];
if (url) {
setBannerImage(url);
const imageId = extractImageId(url);
if (imageId) {
setBannerImageId(imageId);
}
}
}}
onUploadError={(error) => {
toast({
title: "Upload Error",
description: error.message,
variant: "destructive"
});
}}
/> />
<p className="text-xs text-muted-foreground">
High-resolution banner image for the park detail page (recommended: 1200x400px)
</p>
</div>
<div className="space-y-2">
<Label>Card Image</Label>
{cardImage && (
<div className="mb-2">
<img src={cardImage} alt="Current card" className="h-32 w-full object-cover rounded-md" />
</div>
)}
<UppyPhotoUpload
maxFiles={1}
variant="public"
onUploadComplete={(urls) => {
const url = urls[0];
if (url) {
setCardImage(url);
const imageId = extractImageId(url);
if (imageId) {
setCardImageId(imageId);
}
}
}}
onUploadError={(error) => {
toast({
title: "Upload Error",
description: error.message,
variant: "destructive"
});
}}
/>
<p className="text-xs text-muted-foreground">
Square or rectangular image for park cards and listings (recommended: 400x300px)
</p>
</div>
</div>
{/* Form Actions */} {/* Form Actions */}
<div className="flex gap-4 pt-6"> <div className="flex gap-4 pt-6">

View File

@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -61,27 +61,25 @@ const rideSchema = z.object({
technical_specs: z.string().optional(), technical_specs: z.string().optional(),
// Manufacturer and model // Manufacturer and model
manufacturer_id: z.string().uuid().optional(), manufacturer_id: z.string().uuid().optional(),
ride_model_id: z.string().uuid().optional() ride_model_id: z.string().uuid().optional(),
// Images
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string(),
caption: z.string().optional(),
})),
banner_assignment: z.number().optional(),
card_assignment: z.number().optional(),
}).optional()
}); });
type RideFormData = z.infer<typeof rideSchema>; type RideFormData = z.infer<typeof rideSchema>;
interface RideFormProps { interface RideFormProps {
onSubmit: (data: RideFormData & { onSubmit: (data: RideFormData) => Promise<void>;
image_url?: string;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}) => Promise<void>;
onCancel?: () => void; onCancel?: () => void;
initialData?: Partial<RideFormData & { initialData?: Partial<RideFormData>;
image_url?: string;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}>;
isEditing?: boolean; isEditing?: boolean;
} }
@@ -131,10 +129,6 @@ const intensityLevels = [
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) { export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || '');
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
const [cardImageUrl, setCardImageUrl] = useState<string>(initialData?.card_image_url || '');
const [cardImageId, setCardImageId] = useState<string>(initialData?.card_image_id || '');
const { preferences } = useUnitPreferences(); const { preferences } = useUnitPreferences();
const measurementSystem = preferences.measurement_system; const measurementSystem = preferences.measurement_system;
@@ -197,7 +191,8 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
coaster_stats: initialData?.coaster_stats || '', coaster_stats: initialData?.coaster_stats || '',
technical_specs: initialData?.technical_specs || '', technical_specs: initialData?.technical_specs || '',
manufacturer_id: initialData?.manufacturer_id || undefined, manufacturer_id: initialData?.manufacturer_id || undefined,
ride_model_id: initialData?.ride_model_id || undefined ride_model_id: initialData?.ride_model_id || undefined,
images: { uploaded: [] }
} }
}); });
@@ -224,11 +219,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
: undefined, : undefined,
drop_height_meters: data.drop_height_meters drop_height_meters: data.drop_height_meters
? convertDistanceToMetric(data.drop_height_meters, measurementSystem) ? convertDistanceToMetric(data.drop_height_meters, measurementSystem)
: undefined, : undefined
banner_image_url: bannerImageUrl || undefined,
banner_image_id: bannerImageId || undefined,
card_image_url: cardImageUrl || undefined,
card_image_id: cardImageId || undefined
}; };
// Build composite submission if new entities were created // Build composite submission if new entities were created
@@ -714,63 +705,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
</div> </div>
{/* Images */} {/* Images */}
<div className="space-y-6"> <EntityMultiImageUploader
<h3 className="text-lg font-semibold">Images</h3> mode={isEditing ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
<div className="space-y-2"> onChange={(images: ImageAssignments) => setValue('images', images)}
<Label>Banner Image</Label> entityType="ride"
<UppyPhotoUpload
maxFiles={1}
variant="public"
onUploadComplete={(urls) => {
if (urls[0]) {
setBannerImageUrl(urls[0]);
// Extract image ID from Cloudflare URL (format: https://imagedelivery.net/<hash>/<id>/<variant>)
const idMatch = urls[0].match(/\/([^/]+)\/public$/);
if (idMatch) {
setBannerImageId(idMatch[1]);
}
}
}}
showPreview={true}
/> />
{bannerImageUrl && (
<div className="mt-2">
<img src={bannerImageUrl} alt="Banner preview" className="w-full h-32 object-cover rounded" />
</div>
)}
<p className="text-xs text-muted-foreground">
High-resolution banner image for the ride detail page (recommended: 1200x400px)
</p>
</div>
<div className="space-y-2">
<Label>Card Image</Label>
<UppyPhotoUpload
maxFiles={1}
variant="public"
onUploadComplete={(urls) => {
if (urls[0]) {
setCardImageUrl(urls[0]);
// Extract image ID from Cloudflare URL
const idMatch = urls[0].match(/\/([^/]+)\/public$/);
if (idMatch) {
setCardImageId(idMatch[1]);
}
}
}}
showPreview={true}
/>
{cardImageUrl && (
<div className="mt-2">
<img src={cardImageUrl} alt="Card preview" className="w-full h-32 object-cover rounded" />
</div>
)}
<p className="text-xs text-muted-foreground">
Square or rectangular image for ride cards and listings (recommended: 400x300px)
</p>
</div>
</div>
{/* Form Actions */} {/* Form Actions */}
<div className="flex gap-4 pt-6"> <div className="flex gap-4 pt-6">

View File

@@ -202,7 +202,7 @@ export function EntityMultiImageUploader({
onUploadComplete={handleUploadComplete} onUploadComplete={handleUploadComplete}
maxFiles={maxImages - value.uploaded.length} maxFiles={maxImages - value.uploaded.length}
variant="compact" variant="compact"
allowedFileTypes={['image/jpeg', 'image/jpg', 'image/png', 'image/webp']} allowedFileTypes={['image/*']}
/> />
</div> </div>
</Card> </Card>

View File

@@ -77,15 +77,20 @@ export function UppyPhotoUpload({
return `File "${file.name}" exceeds ${maxSizeMB}MB limit`; return `File "${file.name}" exceeds ${maxSizeMB}MB limit`;
} }
const allowedTypes = allowedFileTypes.map(type => // Check if file type is allowed
type.replace('*', '').replace('/', '') // Support both wildcard (image/*) and specific types (image/jpeg, image/png)
); const isWildcardMatch = allowedFileTypes.some(type => {
if (type.includes('*')) {
if (!allowedTypes.includes('image') && !allowedFileTypes.includes('image/*')) { const prefix = type.split('/')[0];
const fileType = file.type.split('/')[0]; return file.type.startsWith(prefix + '/');
if (!allowedTypes.includes(fileType)) {
return `File type "${file.type}" is not allowed`;
} }
return false;
});
const isExactMatch = allowedFileTypes.includes(file.type);
if (!isWildcardMatch && !isExactMatch) {
return `File type "${file.type}" is not allowed`;
} }
return null; return null;

View File

@@ -667,10 +667,6 @@ export default function ParkDetail() {
website_url: park?.website_url, website_url: park?.website_url,
phone: park?.phone, phone: park?.phone,
email: park?.email, email: park?.email,
banner_image_url: park?.banner_image_url,
banner_image_id: park?.banner_image_id,
card_image_url: park?.card_image_url,
card_image_id: park?.card_image_id,
operator_id: park?.operator?.id, operator_id: park?.operator?.id,
property_owner_id: park?.property_owner?.id property_owner_id: park?.property_owner?.id
}} }}

View File

@@ -770,10 +770,6 @@ export default function RideDetail() {
intensity_level: ride.intensity_level, intensity_level: ride.intensity_level,
drop_height_meters: ride.drop_height_meters, drop_height_meters: ride.drop_height_meters,
max_g_force: ride.max_g_force, max_g_force: ride.max_g_force,
banner_image_url: ride.banner_image_url,
banner_image_id: ride.banner_image_id,
card_image_url: ride.card_image_url,
card_image_id: ride.card_image_id,
manufacturer_id: ride.manufacturer?.id, manufacturer_id: ride.manufacturer?.id,
ride_model_id: ride.ride_model?.id ride_model_id: ride.ride_model?.id
}} }}