mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
Refactor: Make photo upload reusable
This commit is contained in:
@@ -1,166 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
/**
|
||||||
import { Camera, Upload, LogIn } from 'lucide-react';
|
* @deprecated Use EntityPhotoGallery directly or import from RidePhotoGalleryWrapper
|
||||||
import { Button } from '@/components/ui/button';
|
* This file is kept for backwards compatibility
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
*/
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
export { RidePhotoGallery } from './RidePhotoGalleryWrapper';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
|
|
||||||
interface RidePhoto {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
caption?: string;
|
|
||||||
title?: string;
|
|
||||||
user_id: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RidePhotoGalleryProps {
|
|
||||||
rideId: string;
|
|
||||||
rideName: string;
|
|
||||||
parkId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RidePhotoGallery({ rideId, rideName, parkId }: RidePhotoGalleryProps) {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [photos, setPhotos] = useState<RidePhoto[]>([]);
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPhotos();
|
|
||||||
}, [rideId]);
|
|
||||||
|
|
||||||
const fetchPhotos = async () => {
|
|
||||||
try {
|
|
||||||
// For now, we'll show a placeholder since approved photos would come from content_submissions
|
|
||||||
// In a real implementation, you'd fetch approved photo submissions
|
|
||||||
setPhotos([]); // Placeholder - no photos yet
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching photos:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
if (!user) {
|
|
||||||
navigate('/auth');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowUpload(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmissionComplete = () => {
|
|
||||||
setShowUpload(false);
|
|
||||||
fetchPhotos(); // Refresh photos after submission
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showUpload) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">Upload Photos for {rideName}</h3>
|
|
||||||
<Button variant="ghost" onClick={() => setShowUpload(false)}>
|
|
||||||
Back to Gallery
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<UppyPhotoSubmissionUpload
|
|
||||||
rideId={rideId}
|
|
||||||
parkId={parkId}
|
|
||||||
onSubmissionComplete={handleSubmissionComplete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-pulse flex items-center gap-3">
|
|
||||||
<Camera className="w-8 h-8 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">Loading photos...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Upload Button */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Photo Gallery</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Share your photos of {rideName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUploadClick} className="gap-2">
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
Upload Photos
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to Upload
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Photo Grid */}
|
|
||||||
{photos.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
{photos.map((photo) => (
|
|
||||||
<Card key={photo.id} className="overflow-hidden">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<img
|
|
||||||
src={photo.url}
|
|
||||||
alt={photo.title || photo.caption || 'Ride photo'}
|
|
||||||
className="w-full h-48 object-cover hover:scale-105 transition-transform cursor-pointer"
|
|
||||||
/>
|
|
||||||
{(photo.title || photo.caption) && (
|
|
||||||
<div className="p-3">
|
|
||||||
{photo.title && (
|
|
||||||
<h4 className="font-medium text-sm truncate">{photo.title}</h4>
|
|
||||||
)}
|
|
||||||
{photo.caption && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
|
||||||
{photo.caption}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Camera className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<h3 className="text-xl font-semibold mb-2">No Photos Yet</h3>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
Be the first to share photos of {rideName}!
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleUploadClick} className="gap-2">
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
Upload First Photo
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to Upload
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
22
src/components/rides/RidePhotoGalleryWrapper.tsx
Normal file
22
src/components/rides/RidePhotoGalleryWrapper.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
|
||||||
|
|
||||||
|
interface RidePhotoGalleryProps {
|
||||||
|
rideId: string;
|
||||||
|
rideName: string;
|
||||||
|
parkId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backwards-compatible wrapper for RidePhotoGallery
|
||||||
|
* Uses the generic EntityPhotoGallery component internally
|
||||||
|
*/
|
||||||
|
export function RidePhotoGallery({ rideId, rideName, parkId }: RidePhotoGalleryProps) {
|
||||||
|
return (
|
||||||
|
<EntityPhotoGallery
|
||||||
|
entityId={rideId}
|
||||||
|
entityType="ride"
|
||||||
|
entityName={rideName}
|
||||||
|
parentId={parkId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/upload/EntityPhotoGallery.tsx
Normal file
196
src/components/upload/EntityPhotoGallery.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Camera, Upload, LogIn } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
caption?: string;
|
||||||
|
title?: string;
|
||||||
|
user_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityPhotoGallery({
|
||||||
|
entityId,
|
||||||
|
entityType,
|
||||||
|
entityName,
|
||||||
|
parentId
|
||||||
|
}: EntityPhotoGalleryProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPhotos();
|
||||||
|
}, [entityId, entityType]);
|
||||||
|
|
||||||
|
const fetchPhotos = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch approved photo submissions for this entity
|
||||||
|
// Support both new (entity_id) and legacy (park_id/ride_id) formats
|
||||||
|
const { data: submissions, error } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('id, content, created_at, user_id')
|
||||||
|
.eq('status', 'approved')
|
||||||
|
.eq('submission_type', 'photo')
|
||||||
|
.or(`content->entity_id.eq.${entityId},content->park_id.eq.${entityId},content->ride_id.eq.${entityId},content->company_id.eq.${entityId}`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Extract photos from submissions
|
||||||
|
const extractedPhotos: Photo[] = [];
|
||||||
|
submissions?.forEach((submission) => {
|
||||||
|
const content = submission.content as any;
|
||||||
|
if (content.photos && Array.isArray(content.photos)) {
|
||||||
|
content.photos.forEach((photo: any) => {
|
||||||
|
extractedPhotos.push({
|
||||||
|
id: `${submission.id}-${photo.order}`,
|
||||||
|
url: photo.url,
|
||||||
|
caption: photo.caption,
|
||||||
|
title: photo.title,
|
||||||
|
user_id: submission.user_id,
|
||||||
|
created_at: submission.created_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPhotos(extractedPhotos);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching photos:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
if (!user) {
|
||||||
|
navigate('/auth');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowUpload(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmissionComplete = () => {
|
||||||
|
setShowUpload(false);
|
||||||
|
fetchPhotos(); // Refresh photos after submission
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showUpload) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Upload Photos for {entityName}</h3>
|
||||||
|
<Button variant="ghost" onClick={() => setShowUpload(false)}>
|
||||||
|
Back to Gallery
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<UppyPhotoSubmissionUpload
|
||||||
|
entityId={entityId}
|
||||||
|
entityType={entityType}
|
||||||
|
parentId={parentId}
|
||||||
|
onSubmissionComplete={handleSubmissionComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-pulse flex items-center gap-3">
|
||||||
|
<Camera className="w-8 h-8 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Loading photos...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upload Button */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Photo Gallery</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Share your photos of {entityName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleUploadClick} className="gap-2">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Upload Photos
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Sign in to Upload
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
{photos.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<Card key={photo.id} className="overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.title || photo.caption || `${entityName} photo`}
|
||||||
|
className="w-full h-48 object-cover hover:scale-105 transition-transform cursor-pointer"
|
||||||
|
/>
|
||||||
|
{(photo.title || photo.caption) && (
|
||||||
|
<div className="p-3">
|
||||||
|
{photo.title && (
|
||||||
|
<h4 className="font-medium text-sm truncate">{photo.title}</h4>
|
||||||
|
)}
|
||||||
|
{photo.caption && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||||
|
{photo.caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Camera className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">No Photos Yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Be the first to share photos of {entityName}!
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleUploadClick} className="gap-2">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Upload First Photo
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Sign in to Upload
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,18 +13,21 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Camera, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
import { Camera, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||||
|
import { UppyPhotoSubmissionUploadProps } from '@/types/submissions';
|
||||||
interface UppyPhotoSubmissionUploadProps {
|
|
||||||
onSubmissionComplete?: () => void;
|
|
||||||
parkId?: string;
|
|
||||||
rideId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UppyPhotoSubmissionUpload({
|
export function UppyPhotoSubmissionUpload({
|
||||||
onSubmissionComplete,
|
onSubmissionComplete,
|
||||||
|
entityId,
|
||||||
|
entityType,
|
||||||
|
parentId,
|
||||||
|
// Legacy props (deprecated)
|
||||||
parkId,
|
parkId,
|
||||||
rideId,
|
rideId,
|
||||||
}: UppyPhotoSubmissionUploadProps) {
|
}: UppyPhotoSubmissionUploadProps) {
|
||||||
|
// Support legacy props
|
||||||
|
const finalEntityId = entityId || rideId || parkId || '';
|
||||||
|
const finalEntityType = entityType || (rideId ? 'ride' : parkId ? 'park' : 'ride');
|
||||||
|
const finalParentId = parentId || (rideId ? parkId : undefined);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -193,10 +196,14 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
date: photo.date?.toISOString(),
|
date: photo.date?.toISOString(),
|
||||||
order: index,
|
order: index,
|
||||||
})),
|
})),
|
||||||
// NEW STRUCTURE: context as string, IDs at top level
|
// NEW STRUCTURE: Generic entity references
|
||||||
context: rideId ? 'ride' : parkId ? 'park' : undefined,
|
context: finalEntityType,
|
||||||
ride_id: rideId,
|
entity_id: finalEntityId,
|
||||||
park_id: parkId,
|
// Legacy structure for backwards compatibility
|
||||||
|
...(finalEntityType === 'ride' && { ride_id: finalEntityId }),
|
||||||
|
...(finalEntityType === 'park' && { park_id: finalEntityId }),
|
||||||
|
...(finalParentId && finalEntityType === 'ride' && { park_id: finalParentId }),
|
||||||
|
...(['manufacturer', 'operator', 'designer', 'property_owner'].includes(finalEntityType) && { company_id: finalEntityId }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,8 +256,12 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
submissionType: 'photo',
|
submissionType: 'photo',
|
||||||
parkId,
|
entityId: finalEntityId,
|
||||||
rideId,
|
entityType: finalEntityType,
|
||||||
|
parentId: finalParentId,
|
||||||
|
// Legacy support
|
||||||
|
parkId: finalEntityType === 'park' ? finalEntityId : finalParentId,
|
||||||
|
rideId: finalEntityType === 'ride' ? finalEntityId : undefined,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
44
src/types/submissions.ts
Normal file
44
src/types/submissions.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export type EntityType =
|
||||||
|
| 'park'
|
||||||
|
| 'ride'
|
||||||
|
| 'manufacturer'
|
||||||
|
| 'operator'
|
||||||
|
| 'designer'
|
||||||
|
| 'property_owner';
|
||||||
|
|
||||||
|
export interface PhotoSubmission {
|
||||||
|
url: string;
|
||||||
|
caption?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoSubmissionContent {
|
||||||
|
title?: string;
|
||||||
|
photos: PhotoSubmission[];
|
||||||
|
context: EntityType;
|
||||||
|
entity_id: string;
|
||||||
|
// Legacy support
|
||||||
|
park_id?: string;
|
||||||
|
ride_id?: string;
|
||||||
|
company_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityPhotoGalleryProps {
|
||||||
|
entityId: string;
|
||||||
|
entityType: EntityType;
|
||||||
|
entityName: string;
|
||||||
|
parentId?: string; // e.g., parkId for a ride
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UppyPhotoSubmissionUploadProps {
|
||||||
|
onSubmissionComplete?: () => void;
|
||||||
|
entityId: string;
|
||||||
|
entityType: EntityType;
|
||||||
|
parentId?: string; // Optional parent (e.g., parkId for rides)
|
||||||
|
|
||||||
|
// Deprecated (kept for backwards compatibility)
|
||||||
|
parkId?: string;
|
||||||
|
rideId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Update content_submissions submission_type to support all entity types
|
||||||
|
ALTER TABLE public.content_submissions
|
||||||
|
DROP CONSTRAINT IF EXISTS content_submissions_submission_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.content_submissions
|
||||||
|
ADD CONSTRAINT content_submissions_submission_type_check
|
||||||
|
CHECK (submission_type IN (
|
||||||
|
'park',
|
||||||
|
'ride',
|
||||||
|
'review',
|
||||||
|
'photo',
|
||||||
|
'manufacturer',
|
||||||
|
'operator',
|
||||||
|
'designer',
|
||||||
|
'property_owner'
|
||||||
|
));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.content_submissions.content IS 'JSONB structure: { title?, photos: Array<{url, caption?, title?, date?, order}>, context: "park"|"ride"|"manufacturer"|"operator"|"designer"|"property_owner", entity_id: string, park_id?: string (legacy), ride_id?: string (legacy), company_id?: string (legacy) }';
|
||||||
Reference in New Issue
Block a user