mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Implement Park Form and Edit/Add Functionality
This commit is contained in:
@@ -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"
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -157,6 +162,83 @@ export default function ParkDetail() {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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,12 +272,22 @@ 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">
|
||||
{/* 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">
|
||||
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,6 +360,15 @@ export default function Parks() {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleAddParkClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Park
|
||||
</Button>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -290,6 +381,7 @@ export default function Parks() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Controls */}
|
||||
<div className="mb-6 space-y-4">
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user