This commit is contained in:
pacnpal
2025-10-04 14:34:37 +00:00
97 changed files with 6202 additions and 1347 deletions

View File

@@ -9,6 +9,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -43,6 +53,9 @@ export function PhotoManagementDialog({
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
const [deleteReason, setDeleteReason] = useState('');
const { toast } = useToast();
useEffect(() => {
@@ -77,55 +90,133 @@ export function PhotoManagementDialog({
const deletePhoto = async (photoId: string) => {
if (!confirm('Are you sure you want to delete this photo?')) return;
const handleDeleteClick = (photo: Photo) => {
setPhotoToDelete(photo);
setDeleteReason('');
setDeleteDialogOpen(true);
};
const requestPhotoDelete = async () => {
if (!photoToDelete || !deleteReason.trim()) return;
try {
const { error } = await supabase.from('photos').delete().eq('id', photoId);
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
if (error) throw error;
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
.insert([{
user_id: user.id,
submission_type: 'photo_delete',
content: {
action: 'delete',
photo_id: photoToDelete.id
}
}])
.select()
.single();
if (submissionError) throw submissionError;
// Create submission item
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submission.id,
item_type: 'photo_delete',
item_data: {
photo_id: photoToDelete.id,
entity_type: entityType,
entity_id: entityId,
cloudflare_image_url: photoToDelete.cloudflare_image_url,
caption: photoToDelete.caption,
reason: deleteReason
},
status: 'pending'
});
if (itemError) throw itemError;
await fetchPhotos();
toast({
title: 'Success',
description: 'Photo deleted',
title: 'Delete request submitted',
description: 'Your photo deletion request has been submitted for moderation',
});
onUpdate?.();
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
onOpenChange(false);
} catch (error) {
console.error('Error deleting photo:', error);
console.error('Error requesting photo deletion:', error);
toast({
title: 'Error',
description: 'Failed to delete photo',
description: 'Failed to submit deletion request',
variant: 'destructive',
});
}
};
const updatePhoto = async () => {
const requestPhotoEdit = async () => {
if (!editingPhoto) return;
try {
const { error } = await supabase
.from('photos')
.update({
caption: editingPhoto.caption,
})
.eq('id', editingPhoto.id);
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
if (error) throw error;
// Get original photo data
const originalPhoto = photos.find(p => p.id === editingPhoto.id);
if (!originalPhoto) throw new Error('Original photo not found');
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
.insert([{
user_id: user.id,
submission_type: 'photo_edit',
content: {
action: 'edit',
photo_id: editingPhoto.id
}
}])
.select()
.single();
if (submissionError) throw submissionError;
// Create submission item
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submission.id,
item_type: 'photo_edit',
item_data: {
photo_id: editingPhoto.id,
entity_type: entityType,
entity_id: entityId,
new_caption: editingPhoto.caption,
cloudflare_image_url: editingPhoto.cloudflare_image_url,
},
original_data: {
caption: originalPhoto.caption,
},
status: 'pending'
});
if (itemError) throw itemError;
await fetchPhotos();
setEditingPhoto(null);
toast({
title: 'Success',
description: 'Photo updated',
title: 'Edit request submitted',
description: 'Your photo edit has been submitted for moderation',
});
onUpdate?.();
onOpenChange(false);
} catch (error) {
console.error('Error updating photo:', error);
console.error('Error requesting photo edit:', error);
toast({
title: 'Error',
description: 'Failed to update photo',
description: 'Failed to submit edit request',
variant: 'destructive',
});
}
@@ -167,7 +258,7 @@ export function PhotoManagementDialog({
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
Cancel
</Button>
<Button onClick={updatePhoto}>Save Changes</Button>
<Button onClick={requestPhotoEdit}>Submit for Review</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -223,16 +314,16 @@ export function PhotoManagementDialog({
className="flex-1 sm:flex-initial"
>
<Pencil className="w-4 h-4 mr-2" />
Edit
Request Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => deletePhoto(photo.id)}
onClick={() => handleDeleteClick(photo)}
className="flex-1 sm:flex-initial"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
Request Delete
</Button>
</div>
</div>
@@ -249,6 +340,44 @@ export function PhotoManagementDialog({
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Request Photo Deletion</AlertDialogTitle>
<AlertDialogDescription>
Please provide a reason for deleting this photo. This request will be reviewed by moderators.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-reason">Reason for deletion</Label>
<Textarea
id="delete-reason"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder="Please explain why this photo should be deleted..."
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={requestPhotoDelete}
disabled={!deleteReason.trim()}
>
Submit Request
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@@ -1,296 +0,0 @@
/**
* @deprecated This component is deprecated. Use UppyPhotoSubmissionUpload instead.
* This file is kept for backwards compatibility only.
*
* For new implementations, use:
* - UppyPhotoSubmissionUpload for direct uploads
* - EntityPhotoGallery for entity-specific photo galleries
*/
import { useState } from 'react';
import { Upload, X, Camera } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
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 { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
interface PhotoSubmissionUploadProps {
onSubmissionComplete?: () => void;
parkId?: string;
rideId?: string;
}
export function PhotoSubmissionUpload({ onSubmissionComplete, parkId, rideId }: PhotoSubmissionUploadProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [caption, setCaption] = useState('');
const [title, setTitle] = useState('');
const { toast } = useToast();
const { user } = useAuth();
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length !== files.length) {
toast({
title: "Invalid Files",
description: "Only image files are allowed",
variant: "destructive",
});
}
setSelectedFiles(prev => [...prev, ...imageFiles].slice(0, 5)); // Max 5 files
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
};
const uploadFileToCloudflare = async (file: File): Promise<{ id: string; url: string }> => {
try {
// Get upload URL from Supabase edge function
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
method: 'POST',
});
if (uploadError || !uploadData?.uploadURL) {
console.error('Failed to get upload URL:', uploadError);
throw new Error('Failed to get upload URL');
}
// Upload file directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadData.uploadURL, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('Cloudflare upload failed:', errorText);
throw new Error('Failed to upload file to Cloudflare');
}
const result = await uploadResponse.json();
const imageId = result.result?.id;
if (!imageId) {
console.error('No image ID returned from Cloudflare:', result);
throw new Error('Invalid response from Cloudflare');
}
// Get the delivery URL using the edge function with URL parameters
const getImageUrl = new URL(`https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image`);
getImageUrl.searchParams.set('id', imageId);
const response = await fetch(getImageUrl.toString(), {
method: 'GET',
headers: {
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4`,
'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to get image status:', errorText);
throw new Error('Failed to get image URL');
}
const statusData = await response.json();
if (!statusData?.urls?.public) {
console.error('No image URL returned:', statusData);
throw new Error('No image URL available');
}
return {
id: imageId,
url: statusData.urls.public,
};
} catch (error) {
console.error('Upload error:', error);
throw error;
}
};
const handleSubmit = async () => {
if (!user) {
toast({
title: "Authentication Required",
description: "Please log in to submit photos",
variant: "destructive",
});
return;
}
if (selectedFiles.length === 0) {
toast({
title: "No Files Selected",
description: "Please select at least one image to submit",
variant: "destructive",
});
return;
}
setUploading(true);
try {
// Upload files to Cloudflare Images first
const photoSubmissions = await Promise.all(
selectedFiles.map(async (file, index) => {
const uploadResult = await uploadFileToCloudflare(file);
return {
filename: file.name,
size: file.size,
type: file.type,
url: uploadResult.url,
imageId: uploadResult.id,
caption: index === 0 ? caption : '', // Only first image gets the caption
};
})
);
// Submit to content_submissions table
const { error } = await supabase
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: 'photo',
content: {
photos: photoSubmissions,
title: title.trim() || undefined,
caption: caption.trim() || undefined,
park_id: parkId,
ride_id: rideId,
context: parkId ? 'park' : rideId ? 'ride' : 'general',
},
status: 'pending',
});
if (error) throw error;
toast({
title: "Photos Submitted",
description: "Your photos have been submitted for moderation review",
});
// Reset form
setSelectedFiles([]);
setCaption('');
setTitle('');
onSubmissionComplete?.();
} catch (error) {
console.error('Error submitting photos:', error);
toast({
title: "Submission Failed",
description: "Failed to submit photos. Please try again.",
variant: "destructive",
});
} finally {
setUploading(false);
}
};
return (
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<Camera className="w-5 h-5" />
<h3 className="text-lg font-semibold">Submit Photos</h3>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="photo-title">Title (optional)</Label>
<Input
id="photo-title"
placeholder="Give your photo submission a title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={100}
/>
</div>
<div>
<Label htmlFor="photo-caption">Caption (optional)</Label>
<Textarea
id="photo-caption"
placeholder="Add a caption to describe your photos"
value={caption}
onChange={(e) => setCaption(e.target.value)}
rows={3}
maxLength={500}
/>
</div>
<div>
<Label htmlFor="photo-upload">Select Photos</Label>
<div className="mt-2">
<input
id="photo-upload"
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('photo-upload')?.click()}
className="w-full"
disabled={selectedFiles.length >= 5}
>
<Upload className="w-4 h-4 mr-2" />
Choose Photos {selectedFiles.length > 0 && `(${selectedFiles.length}/5)`}
</Button>
</div>
</div>
{selectedFiles.length > 0 && (
<div className="space-y-3">
<Label>Selected Photos:</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{selectedFiles.map((file, index) => (
<div key={index} className="relative">
<img
src={URL.createObjectURL(file)}
alt={`Preview ${index + 1}`}
className="w-full h-24 object-cover rounded border"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
onClick={() => removeFile(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
<Button
onClick={handleSubmit}
disabled={uploading || selectedFiles.length === 0}
className="w-full"
>
{uploading ? 'Submitting...' : 'Submit Photos for Review'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -281,12 +281,9 @@ export function PhotoUpload({
console.error('Failed to load avatar image:', uploadedImages[0].thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Avatar image loaded successfully:', uploadedImages[0].thumbnailUrl);
}}
/>
) : existingPhotos.length > 0 ? (
<img
<img
src={existingPhotos[0]}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-2 border-border"
@@ -432,9 +429,6 @@ export function PhotoUpload({
console.error('Failed to load image:', image.thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Image loaded successfully:', image.thumbnailUrl);
}}
/>
<Button
variant="destructive"
@@ -470,9 +464,6 @@ export function PhotoUpload({
console.error('Failed to load existing image:', url);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Existing image loaded successfully:', url);
}}
/>
<Badge variant="outline" className="absolute bottom-2 left-2 text-xs">
Existing

View File

@@ -20,14 +20,7 @@ export function UppyPhotoSubmissionUpload({
entityId,
entityType,
parentId,
// Legacy props (deprecated)
parkId,
rideId,
}: 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 [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -203,9 +196,9 @@ export function UppyPhotoSubmissionUpload({
.from('photo_submissions')
.insert({
submission_id: submissionData.id,
entity_type: finalEntityType,
entity_id: finalEntityId,
parent_id: finalParentId || null,
entity_type: entityType,
entity_id: entityId,
parent_id: parentId || null,
title: title.trim() || null,
})
.select()
@@ -239,8 +232,8 @@ export function UppyPhotoSubmissionUpload({
console.log('✅ Photo submission created:', {
submission_id: submissionData.id,
photo_submission_id: photoSubmissionData.id,
entity_type: finalEntityType,
entity_id: finalEntityId,
entity_type: entityType,
entity_id: entityId,
photo_count: photoItems.length,
});
@@ -285,12 +278,9 @@ export function UppyPhotoSubmissionUpload({
const metadata = {
submissionType: 'photo',
entityId: finalEntityId,
entityType: finalEntityType,
parentId: finalParentId,
// Legacy support
parkId: finalEntityType === 'park' ? finalEntityId : finalParentId,
rideId: finalEntityType === 'ride' ? finalEntityId : undefined,
entityId,
entityType,
parentId,
userId: user?.id,
};