From 7bbf67156b847178517f756a69c2468aedb100ca Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:40:49 +0000 Subject: [PATCH] feat: Implement photo processing logic --- src/components/upload/EntityPhotoGallery.tsx | 99 ++--- .../upload/PhotoManagementDialog.tsx | 385 ++++++++++++++++++ src/integrations/supabase/types.ts | 60 +++ src/lib/submissionItemsService.ts | 153 ++++++- ...4_ee0946d3-b149-42f9-a902-d61abb73052d.sql | 58 +++ 5 files changed, 702 insertions(+), 53 deletions(-) create mode 100644 src/components/upload/PhotoManagementDialog.tsx create mode 100644 supabase/migrations/20250930153834_ee0946d3-b149-42f9-a902-d61abb73052d.sql diff --git a/src/components/upload/EntityPhotoGallery.tsx b/src/components/upload/EntityPhotoGallery.tsx index 2ce16260..7ef1349d 100644 --- a/src/components/upload/EntityPhotoGallery.tsx +++ b/src/components/upload/EntityPhotoGallery.tsx @@ -1,12 +1,14 @@ import { useState, useEffect } from 'react'; -import { Camera, Upload, LogIn } from 'lucide-react'; +import { Camera, Upload, LogIn, Settings } 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 { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog'; import { supabase } from '@/integrations/supabase/client'; import { EntityPhotoGalleryProps } from '@/types/submissions'; +import { useUserRole } from '@/hooks/useUserRole'; interface Photo { id: string; @@ -25,8 +27,10 @@ export function EntityPhotoGallery({ }: EntityPhotoGalleryProps) { const { user } = useAuth(); const navigate = useNavigate(); + const { isModerator } = useUserRole(); const [photos, setPhotos] = useState([]); const [showUpload, setShowUpload] = useState(false); + const [showManagement, setShowManagement] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { @@ -35,42 +39,28 @@ export function EntityPhotoGallery({ const fetchPhotos = async () => { try { - // Fetch approved photo submissions for this entity - // Use separate queries for each legacy field to avoid JSONB UUID parsing issues - let query = supabase - .from('content_submissions') - .select('id, content, created_at, user_id') - .eq('status', 'approved') - .eq('submission_type', 'photo') - .order('created_at', { ascending: false }) - .limit(50); - - // Apply entity-specific filters using proper JSONB text casting - const { data: submissions, error } = await query.or( - `content->>entity_id.eq.${entityId},content->>park_id.eq.${entityId},content->>ride_id.eq.${entityId},content->>company_id.eq.${entityId}` - ); + // Fetch photos directly from the photos table + const { data: photoData, error } = await supabase + .from('photos') + .select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .order('order_index', { ascending: true }) + .order('created_at', { ascending: false }); 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, - }); - }); - } - }); + // Map to Photo interface + const mappedPhotos: Photo[] = photoData?.map((photo) => ({ + id: photo.id, + url: photo.cloudflare_image_url, + caption: photo.caption || undefined, + title: photo.title || undefined, + user_id: photo.submitted_by, + created_at: photo.created_at, + })) || []; - setPhotos(extractedPhotos); + setPhotos(mappedPhotos); } catch (error) { console.error('Error fetching photos:', error); } finally { @@ -123,7 +113,7 @@ export function EntityPhotoGallery({ return (
- {/* Upload Button */} + {/* Header with Upload and Management Buttons */}

Photo Gallery

@@ -131,21 +121,38 @@ export function EntityPhotoGallery({ Share your photos of {entityName}

- )} - + +
+ {/* Photo Management Dialog */} + + {/* Photo Grid */} {photos.length > 0 ? (
diff --git a/src/components/upload/PhotoManagementDialog.tsx b/src/components/upload/PhotoManagementDialog.tsx new file mode 100644 index 00000000..825ffc35 --- /dev/null +++ b/src/components/upload/PhotoManagementDialog.tsx @@ -0,0 +1,385 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/hooks/use-toast'; +import { ArrowUp, ArrowDown, Trash2, Star, StarOff } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; + +interface Photo { + id: string; + cloudflare_image_url: string; + title: string | null; + caption: string | null; + order_index: number; + is_featured: boolean; +} + +interface PhotoManagementDialogProps { + entityId: string; + entityType: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onUpdate?: () => void; +} + +export function PhotoManagementDialog({ + entityId, + entityType, + open, + onOpenChange, + onUpdate, +}: PhotoManagementDialogProps) { + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(false); + const [editingPhoto, setEditingPhoto] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + if (open) { + fetchPhotos(); + } + }, [open, entityId, entityType]); + + const fetchPhotos = async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from('photos') + .select('id, cloudflare_image_url, title, caption, order_index, is_featured') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .order('order_index', { ascending: true }); + + if (error) throw error; + setPhotos(data || []); + } catch (error) { + console.error('Error fetching photos:', error); + toast({ + title: 'Error', + description: 'Failed to load photos', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const movePhoto = async (photoId: string, direction: 'up' | 'down') => { + const currentIndex = photos.findIndex((p) => p.id === photoId); + if ( + (direction === 'up' && currentIndex === 0) || + (direction === 'down' && currentIndex === photos.length - 1) + ) { + return; + } + + const newPhotos = [...photos]; + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + [newPhotos[currentIndex], newPhotos[targetIndex]] = [ + newPhotos[targetIndex], + newPhotos[currentIndex], + ]; + + // Update order_index for both photos + const updates = [ + { id: newPhotos[currentIndex].id, order_index: currentIndex }, + { id: newPhotos[targetIndex].id, order_index: targetIndex }, + ]; + + try { + for (const update of updates) { + const { error } = await supabase + .from('photos') + .update({ order_index: update.order_index }) + .eq('id', update.id); + + if (error) throw error; + } + + setPhotos(newPhotos); + toast({ + title: 'Success', + description: 'Photo order updated', + }); + onUpdate?.(); + } catch (error) { + console.error('Error updating photo order:', error); + toast({ + title: 'Error', + description: 'Failed to update photo order', + variant: 'destructive', + }); + } + }; + + const toggleFeatured = async (photoId: string) => { + try { + const photo = photos.find((p) => p.id === photoId); + if (!photo) return; + + // If setting as featured, unset all other photos + if (!photo.is_featured) { + await supabase + .from('photos') + .update({ is_featured: false }) + .eq('entity_type', entityType) + .eq('entity_id', entityId); + } + + const { error } = await supabase + .from('photos') + .update({ is_featured: !photo.is_featured }) + .eq('id', photoId); + + if (error) throw error; + + await fetchPhotos(); + toast({ + title: 'Success', + description: photo.is_featured + ? 'Photo removed from featured' + : 'Photo set as featured', + }); + onUpdate?.(); + } catch (error) { + console.error('Error updating featured status:', error); + toast({ + title: 'Error', + description: 'Failed to update featured status', + variant: 'destructive', + }); + } + }; + + const deletePhoto = async (photoId: string) => { + if (!confirm('Are you sure you want to delete this photo?')) return; + + try { + const { error } = await supabase.from('photos').delete().eq('id', photoId); + + if (error) throw error; + + await fetchPhotos(); + toast({ + title: 'Success', + description: 'Photo deleted', + }); + onUpdate?.(); + } catch (error) { + console.error('Error deleting photo:', error); + toast({ + title: 'Error', + description: 'Failed to delete photo', + variant: 'destructive', + }); + } + }; + + const updatePhoto = async () => { + if (!editingPhoto) return; + + try { + const { error } = await supabase + .from('photos') + .update({ + title: editingPhoto.title, + caption: editingPhoto.caption, + }) + .eq('id', editingPhoto.id); + + if (error) throw error; + + await fetchPhotos(); + setEditingPhoto(null); + toast({ + title: 'Success', + description: 'Photo updated', + }); + onUpdate?.(); + } catch (error) { + console.error('Error updating photo:', error); + toast({ + title: 'Error', + description: 'Failed to update photo', + variant: 'destructive', + }); + } + }; + + if (editingPhoto) { + return ( + + + + Edit Photo + Update photo details + + +
+
+ {editingPhoto.title +
+ +
+ + + setEditingPhoto({ ...editingPhoto, title: e.target.value }) + } + placeholder="Photo title" + /> +
+ +
+ +