Implement Park Form and Edit/Add Functionality

This commit is contained in:
gpt-engineer-app[bot]
2025-09-29 23:45:35 +00:00
parent 97c7a4226d
commit 0ddae7493c
6 changed files with 311 additions and 29 deletions

View File

@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { PhotoUpload } from '@/components/upload/PhotoUpload';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { toast } from '@/hooks/use-toast';
@@ -29,9 +29,9 @@ const parkSchema = z.object({
type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps {
onSubmit: (data: ParkFormData & { banner_image_url?: string; card_image_url?: string }) => Promise<void>;
onSubmit: (data: ParkFormData & { banner_image_url?: string; card_image_url?: string; banner_image_id?: string; card_image_id?: string }) => Promise<void>;
onCancel?: () => void;
initialData?: Partial<ParkFormData & { banner_image_url?: string; card_image_url?: string }>;
initialData?: Partial<ParkFormData & { banner_image_url?: string; card_image_url?: string; banner_image_id?: string; card_image_id?: string }>;
isEditing?: boolean;
}
@@ -59,6 +59,14 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = 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 || '');
// Extract Cloudflare image ID from URL
const extractImageId = (url: string): string => {
const match = url.match(/\/([a-f0-9-]{36})\//);
return match ? match[1] : '';
};
const {
register,
@@ -103,7 +111,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
await onSubmit({
...data,
banner_image_url: bannerImage || undefined,
card_image_url: cardImage || undefined
card_image_url: cardImage || undefined,
banner_image_id: bannerImageId || undefined,
card_image_id: cardImageId || undefined
});
toast({
@@ -283,15 +293,28 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
<div className="space-y-6">
<div className="space-y-2">
<Label>Banner Image</Label>
<PhotoUpload
{bannerImage && (
<div className="mb-2">
<img src={bannerImage} alt="Current banner" className="h-32 w-full object-cover rounded-md" />
</div>
)}
<UppyPhotoUpload
maxFiles={1}
variant="default"
existingPhotos={bannerImage ? [bannerImage] : []}
onUploadComplete={(urls) => setBannerImage(urls[0] || '')}
onError={(error) => {
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,
description: error.message,
variant: "destructive"
});
}}
@@ -303,15 +326,28 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
<div className="space-y-2">
<Label>Card Image</Label>
<PhotoUpload
{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="default"
existingPhotos={cardImage ? [cardImage] : []}
onUploadComplete={(urls) => setCardImage(urls[0] || '')}
onError={(error) => {
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,
description: error.message,
variant: "destructive"
});
}}

View File

@@ -250,7 +250,9 @@ export type Database = {
parks: {
Row: {
average_rating: number | null
banner_image_id: string | null
banner_image_url: string | null
card_image_id: string | null
card_image_url: string | null
closing_date: string | null
coaster_count: number | null
@@ -274,7 +276,9 @@ export type Database = {
}
Insert: {
average_rating?: number | null
banner_image_id?: string | null
banner_image_url?: string | null
card_image_id?: string | null
card_image_url?: string | null
closing_date?: string | null
coaster_count?: number | null
@@ -298,7 +302,9 @@ export type Database = {
}
Update: {
average_rating?: number | null
banner_image_id?: string | null
banner_image_url?: string | null
card_image_id?: string | null
card_image_url?: string | null
closing_date?: string | null
coaster_count?: number | null

View File

@@ -16,7 +16,10 @@ import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
import { supabase } from '@/integrations/supabase/client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { RideForm } from '@/components/admin/RideForm';
import { ParkForm } from '@/components/admin/ParkForm';
import { toast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { Edit } from 'lucide-react';
export default function ParkDetail() {
const {
slug
@@ -29,6 +32,8 @@ export default function ParkDetail() {
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false);
const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false);
const { isModerator } = useUserRole();
useEffect(() => {
if (slug) {
fetchParkData();
@@ -153,10 +158,87 @@ export default function ParkDetail() {
toast({
title: "Submission Failed",
description: error.message || "Failed to submit ride for review.",
variant: "destructive"
});
}
};
const handleEditParkClick = () => {
if (!user) {
navigate('/auth');
return;
}
setIsEditParkModalOpen(true);
};
const handleEditParkSubmit = async (parkData: any) => {
if (!user || !park) return;
try {
if (isModerator()) {
// Moderators can update directly
const { error } = await supabase
.from('parks')
.update({
name: parkData.name,
slug: parkData.slug,
description: parkData.description,
park_type: parkData.park_type,
status: parkData.status,
opening_date: parkData.opening_date || null,
closing_date: parkData.closing_date || null,
website_url: parkData.website_url || null,
phone: parkData.phone || null,
email: parkData.email || null,
banner_image_url: parkData.banner_image_url || null,
banner_image_id: parkData.banner_image_id || null,
card_image_url: parkData.card_image_url || null,
card_image_id: parkData.card_image_id || null,
updated_at: new Date().toISOString()
})
.eq('id', park.id);
if (error) throw error;
toast({
title: "Park Updated",
description: "The park has been updated successfully.",
});
setIsEditParkModalOpen(false);
fetchParkData();
} else {
// Regular users submit for moderation
const { error } = await supabase
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: 'park_edit',
status: 'pending',
content: {
park_id: park.id,
...parkData
}
});
if (error) throw error;
toast({
title: "Edit Submitted",
description: "Your park edit has been submitted for review.",
});
setIsEditParkModalOpen(false);
}
} catch (error: any) {
toast({
title: "Error",
description: error.message || "Failed to update park.",
variant: "destructive"
});
}
};
if (loading) {
return <div className="min-h-screen bg-background">
<Header />
@@ -190,11 +272,21 @@ export default function ParkDetail() {
<Header />
<main className="container mx-auto px-4 py-8">
{/* Back Button */}
<Button variant="ghost" onClick={() => navigate('/parks')} className="mb-6">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
{/* Back Button and Edit Button */}
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
<Button
variant="outline"
onClick={handleEditParkClick}
>
<Edit className="w-4 h-4 mr-2" />
Edit Park
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
@@ -544,6 +636,39 @@ export default function ParkDetail() {
/>
</DialogContent>
</Dialog>
{/* Edit Park Modal */}
<Dialog open={isEditParkModalOpen} onOpenChange={setIsEditParkModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Park</DialogTitle>
<DialogDescription>
Make changes to the park information. {isModerator() ? 'Changes will be applied immediately.' : 'Your changes will be submitted for review.'}
</DialogDescription>
</DialogHeader>
<ParkForm
onSubmit={handleEditParkSubmit}
onCancel={() => setIsEditParkModalOpen(false)}
initialData={{
name: park?.name,
slug: park?.slug,
description: park?.description,
park_type: park?.park_type,
status: park?.status,
opening_date: park?.opening_date,
closing_date: park?.closing_date,
website_url: park?.website_url,
phone: park?.phone,
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
}}
isEditing={true}
/>
</DialogContent>
</Dialog>
</main>
</div>;
}

View File

@@ -15,7 +15,8 @@ import {
ChevronDown,
Sliders,
X,
FerrisWheel
FerrisWheel,
Plus
} from 'lucide-react';
import { Park } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
@@ -26,6 +27,10 @@ import { ParkListView } from '@/components/parks/ParkListView';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ParkSortOptions } from '@/components/parks/ParkSortOptions';
import { useToast } from '@/hooks/use-toast';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ParkForm } from '@/components/admin/ParkForm';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
export interface FilterState {
search: string;
@@ -70,8 +75,11 @@ export default function Parks() {
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [isAddParkModalOpen, setIsAddParkModalOpen] = useState(false);
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const { isModerator } = useUserRole();
useEffect(() => {
fetchParks();
@@ -227,6 +235,80 @@ export default function Parks() {
navigate(`/parks/${park.slug}`);
};
const handleAddParkClick = () => {
if (!user) {
navigate('/auth');
return;
}
setIsAddParkModalOpen(true);
};
const handleParkSubmit = async (parkData: any) => {
if (!user) {
navigate('/auth');
return;
}
try {
if (isModerator()) {
// Moderators can create parks directly
const { error } = await supabase
.from('parks')
.insert({
name: parkData.name,
slug: parkData.slug,
description: parkData.description || null,
park_type: parkData.park_type,
status: parkData.status,
opening_date: parkData.opening_date || null,
closing_date: parkData.closing_date || null,
website_url: parkData.website_url || null,
phone: parkData.phone || null,
email: parkData.email || null,
banner_image_url: parkData.banner_image_url || null,
banner_image_id: parkData.banner_image_id || null,
card_image_url: parkData.card_image_url || null,
card_image_id: parkData.card_image_id || null
});
if (error) throw error;
toast({
title: "Park Created",
description: "The park has been created successfully.",
});
setIsAddParkModalOpen(false);
fetchParks();
} else {
// Regular users submit for moderation
const { error } = await supabase
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: 'park',
status: 'pending',
content: parkData
});
if (error) throw error;
toast({
title: "Submission Sent",
description: "Your park submission has been sent for moderation review.",
});
setIsAddParkModalOpen(false);
}
} catch (error: any) {
toast({
title: "Submission Failed",
description: error.message || "Failed to submit park.",
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
@@ -278,16 +360,26 @@ export default function Parks() {
</Badge>
</div>
{activeFilterCount > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={clearAllFilters}
className="text-destructive hover:text-destructive"
onClick={handleAddParkClick}
className="gap-2"
>
<X className="w-4 h-4 mr-2" />
Clear all filters
<Plus className="w-4 h-4" />
Add Park
</Button>
)}
{activeFilterCount > 0 && (
<Button
variant="outline"
onClick={clearAllFilters}
className="text-destructive hover:text-destructive"
>
<X className="w-4 h-4 mr-2" />
Clear all filters
</Button>
)}
</div>
</div>
</div>
@@ -374,6 +466,23 @@ export default function Parks() {
</Button>
</div>
)}
{/* Add Park Modal */}
<Dialog open={isAddParkModalOpen} onOpenChange={setIsAddParkModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Park</DialogTitle>
<DialogDescription>
Add a new park to the database. {isModerator() ? 'The park will be added immediately.' : 'Your submission will be reviewed before being published.'}
</DialogDescription>
</DialogHeader>
<ParkForm
onSubmit={handleParkSubmit}
onCancel={() => setIsAddParkModalOpen(false)}
isEditing={false}
/>
</DialogContent>
</Dialog>
</main>
</div>
);

View File

@@ -41,7 +41,9 @@ export interface Park {
operator?: Company;
property_owner?: Company;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
average_rating: number;
review_count: number;
ride_count: number;

View File

@@ -0,0 +1,4 @@
-- Add Cloudflare image ID columns to parks table
ALTER TABLE public.parks
ADD COLUMN IF NOT EXISTS banner_image_id TEXT,
ADD COLUMN IF NOT EXISTS card_image_id TEXT;