mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
feat: Implement photo processing logic
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
||||||
|
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,8 +27,10 @@ export function EntityPhotoGallery({
|
|||||||
}: EntityPhotoGalleryProps) {
|
}: EntityPhotoGalleryProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isModerator } = useUserRole();
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showManagement, setShowManagement] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,42 +39,28 @@ export function EntityPhotoGallery({
|
|||||||
|
|
||||||
const fetchPhotos = async () => {
|
const fetchPhotos = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch approved photo submissions for this entity
|
// Fetch photos directly from the photos table
|
||||||
// Use separate queries for each legacy field to avoid JSONB UUID parsing issues
|
const { data: photoData, error } = await supabase
|
||||||
let query = supabase
|
.from('photos')
|
||||||
.from('content_submissions')
|
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
|
||||||
.select('id, content, created_at, user_id')
|
.eq('entity_type', entityType)
|
||||||
.eq('status', 'approved')
|
.eq('entity_id', entityId)
|
||||||
.eq('submission_type', 'photo')
|
.order('order_index', { ascending: true })
|
||||||
.order('created_at', { ascending: false })
|
.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}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// Extract photos from submissions
|
// Map to Photo interface
|
||||||
const extractedPhotos: Photo[] = [];
|
const mappedPhotos: Photo[] = photoData?.map((photo) => ({
|
||||||
submissions?.forEach((submission) => {
|
id: photo.id,
|
||||||
const content = submission.content as any;
|
url: photo.cloudflare_image_url,
|
||||||
if (content.photos && Array.isArray(content.photos)) {
|
caption: photo.caption || undefined,
|
||||||
content.photos.forEach((photo: any) => {
|
title: photo.title || undefined,
|
||||||
extractedPhotos.push({
|
user_id: photo.submitted_by,
|
||||||
id: `${submission.id}-${photo.order}`,
|
created_at: photo.created_at,
|
||||||
url: photo.url,
|
})) || [];
|
||||||
caption: photo.caption,
|
|
||||||
title: photo.title,
|
|
||||||
user_id: submission.user_id,
|
|
||||||
created_at: submission.created_at,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setPhotos(extractedPhotos);
|
setPhotos(mappedPhotos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching photos:', error);
|
console.error('Error fetching photos:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -123,7 +113,7 @@ export function EntityPhotoGallery({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Upload Button */}
|
{/* Header with Upload and Management Buttons */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Photo Gallery</h3>
|
<h3 className="text-lg font-semibold">Photo Gallery</h3>
|
||||||
@@ -131,21 +121,38 @@ export function EntityPhotoGallery({
|
|||||||
Share your photos of {entityName}
|
Share your photos of {entityName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleUploadClick} className="gap-2">
|
<div className="flex gap-2">
|
||||||
{user ? (
|
{isModerator && photos.length > 0 && (
|
||||||
<>
|
<Button onClick={() => setShowManagement(true)} variant="outline" className="gap-2">
|
||||||
<Upload className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
Upload Photos
|
Manage
|
||||||
</>
|
</Button>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to Upload
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Management Dialog */}
|
||||||
|
<PhotoManagementDialog
|
||||||
|
entityId={entityId}
|
||||||
|
entityType={entityType}
|
||||||
|
open={showManagement}
|
||||||
|
onOpenChange={setShowManagement}
|
||||||
|
onUpdate={fetchPhotos}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
{photos.length > 0 ? (
|
{photos.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
|||||||
385
src/components/upload/PhotoManagementDialog.tsx
Normal file
385
src/components/upload/PhotoManagementDialog.tsx
Normal file
@@ -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<Photo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Photo</DialogTitle>
|
||||||
|
<DialogDescription>Update photo details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="aspect-video w-full overflow-hidden rounded-lg">
|
||||||
|
<img
|
||||||
|
src={editingPhoto.cloudflare_image_url}
|
||||||
|
alt={editingPhoto.title || 'Photo'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={editingPhoto.title || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingPhoto({ ...editingPhoto, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Photo title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="caption">Caption</Label>
|
||||||
|
<Textarea
|
||||||
|
id="caption"
|
||||||
|
value={editingPhoto.caption || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingPhoto({ ...editingPhoto, caption: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Photo caption"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updatePhoto}>Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Photos</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Reorder, edit, or delete photos for this entity
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-pulse">Loading photos...</div>
|
||||||
|
</div>
|
||||||
|
) : photos.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No photos to manage
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<Card key={photo.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-32 h-32 flex-shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<img
|
||||||
|
src={photo.cloudflare_image_url}
|
||||||
|
alt={photo.title || 'Photo'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">
|
||||||
|
{photo.title || 'Untitled'}
|
||||||
|
</h4>
|
||||||
|
{photo.caption && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{photo.caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.is_featured && (
|
||||||
|
<span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => movePhoto(photo.id, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => movePhoto(photo.id, 'down')}
|
||||||
|
disabled={index === photos.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => toggleFeatured(photo.id)}
|
||||||
|
>
|
||||||
|
{photo.is_featured ? (
|
||||||
|
<StarOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditingPhoto(photo)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deletePhoto(photo.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -370,6 +370,66 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
photos: {
|
||||||
|
Row: {
|
||||||
|
approved_at: string | null
|
||||||
|
approved_by: string | null
|
||||||
|
caption: string | null
|
||||||
|
cloudflare_image_id: string
|
||||||
|
cloudflare_image_url: string
|
||||||
|
created_at: string
|
||||||
|
date_taken: string | null
|
||||||
|
entity_id: string
|
||||||
|
entity_type: string
|
||||||
|
id: string
|
||||||
|
is_featured: boolean | null
|
||||||
|
order_index: number | null
|
||||||
|
photographer_credit: string | null
|
||||||
|
submission_id: string | null
|
||||||
|
submitted_by: string | null
|
||||||
|
title: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
approved_at?: string | null
|
||||||
|
approved_by?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
cloudflare_image_id: string
|
||||||
|
cloudflare_image_url: string
|
||||||
|
created_at?: string
|
||||||
|
date_taken?: string | null
|
||||||
|
entity_id: string
|
||||||
|
entity_type: string
|
||||||
|
id?: string
|
||||||
|
is_featured?: boolean | null
|
||||||
|
order_index?: number | null
|
||||||
|
photographer_credit?: string | null
|
||||||
|
submission_id?: string | null
|
||||||
|
submitted_by?: string | null
|
||||||
|
title?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
approved_at?: string | null
|
||||||
|
approved_by?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
cloudflare_image_id?: string
|
||||||
|
cloudflare_image_url?: string
|
||||||
|
created_at?: string
|
||||||
|
date_taken?: string | null
|
||||||
|
entity_id?: string
|
||||||
|
entity_type?: string
|
||||||
|
id?: string
|
||||||
|
is_featured?: boolean | null
|
||||||
|
order_index?: number | null
|
||||||
|
photographer_credit?: string | null
|
||||||
|
submission_id?: string | null
|
||||||
|
submitted_by?: string | null
|
||||||
|
title?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
profiles: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
avatar_image_id: string | null
|
avatar_image_id: string | null
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export async function approveSubmissionItems(
|
|||||||
entityId = await createRideModel(item.item_data, dependencyMap);
|
entityId = await createRideModel(item.item_data, dependencyMap);
|
||||||
break;
|
break;
|
||||||
case 'photo':
|
case 'photo':
|
||||||
entityId = await approvePhotos(item.item_data, dependencyMap);
|
entityId = await approvePhotos(item.item_data, dependencyMap, userId, item.submission_id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,18 +396,157 @@ async function createRideModel(data: any, dependencyMap: Map<string, string>): P
|
|||||||
return model.id;
|
return model.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approvePhotos(data: any, dependencyMap: Map<string, string>): Promise<string> {
|
async function approvePhotos(data: any, dependencyMap: Map<string, string>, userId: string, submissionId: string): Promise<string> {
|
||||||
// Photos are already uploaded to Cloudflare
|
// Photos are already uploaded to Cloudflare
|
||||||
// Resolve dependencies for entity associations
|
// Resolve dependencies for entity associations
|
||||||
const resolvedData = resolveDependencies(data, dependencyMap);
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
||||||
|
|
||||||
// For now, return the first photo URL
|
if (!resolvedData.photos || !Array.isArray(resolvedData.photos) || resolvedData.photos.length === 0) {
|
||||||
// In the future, this could create photo records in a dedicated table
|
throw new Error('No photos found in submission');
|
||||||
if (resolvedData.photos && Array.isArray(resolvedData.photos) && resolvedData.photos.length > 0) {
|
|
||||||
return resolvedData.photos[0].url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
const { entity_id, context, park_id, ride_id, company_id } = resolvedData;
|
||||||
|
|
||||||
|
// Determine entity_id and entity_type
|
||||||
|
let finalEntityId = entity_id;
|
||||||
|
let entityType = context;
|
||||||
|
|
||||||
|
// Support legacy field names
|
||||||
|
if (!finalEntityId) {
|
||||||
|
if (park_id) {
|
||||||
|
finalEntityId = park_id;
|
||||||
|
entityType = 'park';
|
||||||
|
} else if (ride_id) {
|
||||||
|
finalEntityId = ride_id;
|
||||||
|
entityType = 'ride';
|
||||||
|
} else if (company_id) {
|
||||||
|
finalEntityId = company_id;
|
||||||
|
// Need to determine company type from database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalEntityId || !entityType) {
|
||||||
|
throw new Error('Missing entity_id or context in photo submission');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert photos into the photos table
|
||||||
|
const photosToInsert = resolvedData.photos.map((photo: any, index: number) => {
|
||||||
|
// Extract CloudFlare image ID from URL if not provided
|
||||||
|
let cloudflareImageId = photo.cloudflare_image_id;
|
||||||
|
if (!cloudflareImageId && photo.url) {
|
||||||
|
// URL format: https://imagedelivery.net/{account_hash}/{image_id}/{variant}
|
||||||
|
const urlParts = photo.url.split('/');
|
||||||
|
cloudflareImageId = urlParts[urlParts.length - 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloudflare_image_id: cloudflareImageId,
|
||||||
|
cloudflare_image_url: photo.url,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: finalEntityId,
|
||||||
|
title: photo.title || resolvedData.title,
|
||||||
|
caption: photo.caption,
|
||||||
|
photographer_credit: photo.photographer_credit,
|
||||||
|
date_taken: photo.date || photo.date_taken,
|
||||||
|
order_index: photo.order !== undefined ? photo.order : index,
|
||||||
|
is_featured: index === 0, // First photo is featured by default
|
||||||
|
submission_id: submissionId,
|
||||||
|
submitted_by: userId,
|
||||||
|
approved_by: userId,
|
||||||
|
approved_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: insertedPhotos, error } = await supabase
|
||||||
|
.from('photos')
|
||||||
|
.insert(photosToInsert)
|
||||||
|
.select();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error inserting photos:', error);
|
||||||
|
throw new Error(`Database error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update entity's featured image if this is the first photo
|
||||||
|
if (insertedPhotos && insertedPhotos.length > 0) {
|
||||||
|
const firstPhoto = insertedPhotos[0];
|
||||||
|
await updateEntityFeaturedImage(
|
||||||
|
entityType,
|
||||||
|
finalEntityId,
|
||||||
|
firstPhoto.cloudflare_image_url,
|
||||||
|
firstPhoto.cloudflare_image_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first photo URL for backwards compatibility
|
||||||
|
return resolvedData.photos[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update entity's featured image fields
|
||||||
|
*/
|
||||||
|
async function updateEntityFeaturedImage(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
imageUrl: string,
|
||||||
|
imageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Update based on entity type
|
||||||
|
if (entityType === 'park') {
|
||||||
|
const { data: existingPark } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.select('card_image_url')
|
||||||
|
.eq('id', entityId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingPark && !existingPark.card_image_url) {
|
||||||
|
await supabase
|
||||||
|
.from('parks')
|
||||||
|
.update({
|
||||||
|
card_image_url: imageUrl,
|
||||||
|
card_image_id: imageId,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', entityId);
|
||||||
|
}
|
||||||
|
} else if (entityType === 'ride') {
|
||||||
|
const { data: existingRide } = await supabase
|
||||||
|
.from('rides')
|
||||||
|
.select('card_image_url')
|
||||||
|
.eq('id', entityId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingRide && !existingRide.card_image_url) {
|
||||||
|
await supabase
|
||||||
|
.from('rides')
|
||||||
|
.update({
|
||||||
|
card_image_url: imageUrl,
|
||||||
|
card_image_id: imageId,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', entityId);
|
||||||
|
}
|
||||||
|
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
|
||||||
|
const { data: existingCompany } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.select('logo_url')
|
||||||
|
.eq('id', entityId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingCompany && !existingCompany.logo_url) {
|
||||||
|
await supabase
|
||||||
|
.from('companies')
|
||||||
|
.update({
|
||||||
|
logo_url: imageUrl,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating ${entityType} featured image:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Create photos table for storing entity photos with CloudFlare integration
|
||||||
|
CREATE TABLE IF NOT EXISTS public.photos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
cloudflare_image_id TEXT NOT NULL,
|
||||||
|
cloudflare_image_url TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('park', 'ride', 'manufacturer', 'operator', 'designer', 'property_owner')),
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
photographer_credit TEXT,
|
||||||
|
date_taken DATE,
|
||||||
|
order_index INTEGER DEFAULT 0,
|
||||||
|
is_featured BOOLEAN DEFAULT false,
|
||||||
|
submission_id UUID,
|
||||||
|
submitted_by UUID,
|
||||||
|
approved_by UUID,
|
||||||
|
approved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for efficient queries
|
||||||
|
CREATE INDEX idx_photos_entity ON public.photos(entity_type, entity_id);
|
||||||
|
CREATE INDEX idx_photos_submission ON public.photos(submission_id);
|
||||||
|
CREATE INDEX idx_photos_featured ON public.photos(entity_type, entity_id, is_featured) WHERE is_featured = true;
|
||||||
|
CREATE INDEX idx_photos_order ON public.photos(entity_type, entity_id, order_index);
|
||||||
|
|
||||||
|
-- Enable Row Level Security
|
||||||
|
ALTER TABLE public.photos ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Public can view approved photos
|
||||||
|
CREATE POLICY "Public read access to photos"
|
||||||
|
ON public.photos
|
||||||
|
FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Authenticated users can submit photos (for future direct upload)
|
||||||
|
CREATE POLICY "Users can create photos"
|
||||||
|
ON public.photos
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = submitted_by);
|
||||||
|
|
||||||
|
-- Moderators can manage all photos
|
||||||
|
CREATE POLICY "Moderators can update photos"
|
||||||
|
ON public.photos
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators can delete photos"
|
||||||
|
ON public.photos
|
||||||
|
FOR DELETE
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
-- Create trigger to update updated_at timestamp
|
||||||
|
CREATE TRIGGER update_photos_updated_at
|
||||||
|
BEFORE UPDATE ON public.photos
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
Reference in New Issue
Block a user