feat: Implement photo processing logic

This commit is contained in:
gpt-engineer-app[bot]
2025-09-30 15:40:49 +00:00
parent 14c399f293
commit 7bbf67156b
5 changed files with 702 additions and 53 deletions

View File

@@ -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<Photo[]>([]);
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 (
<div className="space-y-6">
{/* Upload Button */}
{/* Header with Upload and Management Buttons */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Photo Gallery</h3>
@@ -131,21 +121,38 @@ export function EntityPhotoGallery({
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
</>
<div className="flex gap-2">
{isModerator && photos.length > 0 && (
<Button onClick={() => setShowManagement(true)} variant="outline" className="gap-2">
<Settings className="w-4 h-4" />
Manage
</Button>
)}
</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>
{/* Photo Management Dialog */}
<PhotoManagementDialog
entityId={entityId}
entityType={entityType}
open={showManagement}
onOpenChange={setShowManagement}
onUpdate={fetchPhotos}
/>
{/* Photo Grid */}
{photos.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">

View 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>
);
}