import React, { useState } from "react"; import { invokeWithTracking } from "@/lib/edgeFunctionTracking"; import { logger } from "@/lib/logger"; import { getErrorMessage } from "@/lib/errorHandler"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { UppyPhotoUploadLazy } from "./UppyPhotoUploadLazy"; import { PhotoCaptionEditor, PhotoWithCaption } from "./PhotoCaptionEditor"; import { supabase } from "@/lib/supabaseClient"; import { useAuth } from "@/hooks/useAuth"; import { useToast } from "@/hooks/use-toast"; import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react"; import { UppyPhotoSubmissionUploadProps } from "@/types/submissions"; export function UppyPhotoSubmissionUpload({ onSubmissionComplete, entityId, entityType, parentId, }: UppyPhotoSubmissionUploadProps) { const [title, setTitle] = useState(""); const [photos, setPhotos] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null); const { user } = useAuth(); const { toast } = useToast(); const handleFilesSelected = (files: File[]) => { // Convert files to photo objects with object URLs for preview const newPhotos: PhotoWithCaption[] = files.map((file, index) => ({ url: URL.createObjectURL(file), // Object URL for preview file, // Store the file for later upload caption: "", order: photos.length + index, uploadStatus: "pending" as const, })); setPhotos((prev) => [...prev, ...newPhotos]); }; const handlePhotosChange = (updatedPhotos: PhotoWithCaption[]) => { setPhotos(updatedPhotos); }; const handleRemovePhoto = (index: number) => { setPhotos((prev) => { const photo = prev[index]; // Revoke object URL if it exists if (photo.file && photo.url.startsWith("blob:")) { URL.revokeObjectURL(photo.url); } return prev.filter((_, i) => i !== index); }); }; const handleSubmit = async () => { if (!user) { toast({ variant: "destructive", title: "Authentication Required", description: "Please sign in to submit photos.", }); return; } if (photos.length === 0) { toast({ variant: "destructive", title: "No Photos", description: "Please upload at least one photo before submitting.", }); return; } setIsSubmitting(true); try { // Upload all photos that haven't been uploaded yet const uploadedPhotos: PhotoWithCaption[] = []; const photosToUpload = photos.filter((p) => p.file); if (photosToUpload.length > 0) { setUploadProgress({ current: 0, total: photosToUpload.length }); for (let i = 0; i < photosToUpload.length; i++) { const photo = photosToUpload[i]; setUploadProgress({ current: i + 1, total: photosToUpload.length }); // Update status setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p))); try { // Get upload URL from edge function const { data: uploadData, error: uploadError } = await invokeWithTracking( "upload-image", { metadata: { requireSignedURLs: false }, variant: "public" }, user?.id, ); if (uploadError) throw uploadError; const { uploadURL, id: cloudflareId } = uploadData; // Upload file to Cloudflare if (!photo.file) { throw new Error("Photo file is missing"); } const formData = new FormData(); formData.append("file", photo.file); const uploadResponse = await fetch(uploadURL, { method: "POST", body: formData, }); if (!uploadResponse.ok) { throw new Error("Failed to upload to Cloudflare"); } // Poll for processing completion let attempts = 0; const maxAttempts = 30; let cloudflareUrl = ""; while (attempts < maxAttempts) { const { data: { session }, } = await supabase.auth.getSession(); const supabaseUrl = "https://api.thrillwiki.com"; const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, { headers: { Authorization: `Bearer ${session?.access_token || ""}`, apikey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4", }, }); if (statusResponse.ok) { const status = await statusResponse.json(); if (status.uploaded && status.urls) { cloudflareUrl = status.urls.public; break; } } await new Promise((resolve) => setTimeout(resolve, 1000)); attempts++; } if (!cloudflareUrl) { throw new Error("Upload processing timeout"); } // Revoke object URL URL.revokeObjectURL(photo.url); uploadedPhotos.push({ ...photo, url: cloudflareUrl, uploadStatus: "uploaded" as const, }); // Update status setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, url: cloudflareUrl, uploadStatus: "uploaded" as const } : p)), ); } catch (error: unknown) { const errorMsg = getErrorMessage(error); logger.error("Photo submission upload failed", { photoTitle: photo.title, photoOrder: photo.order, fileName: photo.file?.name, error: errorMsg, }); setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "failed" as const } : p))); throw new Error(`Failed to upload ${photo.title || "photo"}: ${errorMsg}`); } } } setUploadProgress(null); // Create content_submission record first const { data: submissionData, error: submissionError } = await supabase .from("content_submissions") .insert({ user_id: user.id, submission_type: "photo", content: {}, // Empty content, all data is in relational tables }) .select() .single(); if (submissionError || !submissionData) { throw submissionError || new Error("Failed to create submission record"); } // Create photo_submission record const { data: photoSubmissionData, error: photoSubmissionError } = await supabase .from("photo_submissions") .insert({ submission_id: submissionData.id, entity_type: entityType, entity_id: entityId, parent_id: parentId || null, title: title.trim() || null, }) .select() .single(); if (photoSubmissionError || !photoSubmissionData) { throw photoSubmissionError || new Error("Failed to create photo submission"); } // Insert all photo items const photoItems = photos.map((photo, index) => ({ photo_submission_id: photoSubmissionData.id, cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL cloudflare_image_url: photo.uploadStatus === "uploaded" ? photo.url : uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url, caption: photo.caption.trim() || null, title: photo.title?.trim() || null, filename: photo.file?.name || null, order_index: index, file_size: photo.file?.size || null, mime_type: photo.file?.type || null, })); const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems); if (itemsError) { throw itemsError; } toast({ title: "Submission Successful", description: "Your photos have been submitted for review. Thank you for contributing!", }); // Cleanup and reset form photos.forEach((photo) => { if (photo.url.startsWith("blob:")) { URL.revokeObjectURL(photo.url); } }); setTitle(""); setPhotos([]); onSubmissionComplete?.(); } catch (error: unknown) { const errorMsg = getErrorMessage(error); logger.error("Photo submission failed", { entityType, entityId, photoCount: photos.length, userId: user?.id, error: errorMsg, }); toast({ variant: "destructive", title: "Submission Failed", description: errorMsg || "There was an error submitting your photos. Please try again.", }); } finally { setIsSubmitting(false); setUploadProgress(null); } }; // Cleanup on unmount React.useEffect(() => { return () => { photos.forEach((photo) => { if (photo.url.startsWith("blob:")) { URL.revokeObjectURL(photo.url); } }); }; }, []); const metadata = { submissionType: "photo", entityId, entityType, parentId, userId: user?.id, }; return (
Submit Photos Share your photos with the community. All submissions will be reviewed before being published.

Submission Guidelines:

  • • Photos should be clear and well-lit
  • • Maximum 10 images per submission
  • • Each image up to 25MB in size
  • • Review process takes 24-48 hours
setTitle(e.target.value)} placeholder="Give your photos a descriptive title (optional)" maxLength={100} disabled={isSubmitting} className="transition-all duration-200 focus:ring-2 focus:ring-primary/20" />

{title.length}/100 characters

{photos.length > 0 && ( {photos.length} photo{photos.length !== 1 ? "s" : ""} selected )}
{photos.length > 0 && ( <> )}
{uploadProgress && (
Uploading photos... {uploadProgress.current} of {uploadProgress.total}
)}
Your submission will be reviewed and published within 24-48 hours
); }