diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index f55fe499..4729cc59 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { CheckCircle, XCircle, Eye, Calendar, User, Filter } from 'lucide-react'; +import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -17,21 +17,26 @@ interface ModerationItem { created_at: string; user_id: string; status: string; + submission_type?: string; user_profile?: { username: string; display_name?: string; }; } +type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; +type StatusFilter = 'all' | 'pending' | 'flagged' | 'approved' | 'rejected'; + export function ModerationQueue() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); - const [activeFilter, setActiveFilter] = useState('pending'); + const [activeEntityFilter, setActiveEntityFilter] = useState('all'); + const [activeStatusFilter, setActiveStatusFilter] = useState('pending'); const { toast } = useToast(); - const fetchItems = async (filter: string = 'pending') => { + const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => { try { setLoading(true); @@ -39,7 +44,7 @@ export function ModerationQueue() { let submissionStatuses: string[] = []; // Define status filters - switch (filter) { + switch (statusFilter) { case 'all': reviewStatuses = ['pending', 'flagged', 'approved', 'rejected']; submissionStatuses = ['pending', 'approved', 'rejected']; @@ -67,7 +72,7 @@ export function ModerationQueue() { // Fetch reviews let reviews = []; - if (reviewStatuses.length > 0) { + if ((entityFilter === 'all' || entityFilter === 'reviews') && reviewStatuses.length > 0) { const { data: reviewsData, error: reviewsError } = await supabase .from('reviews') .select(` @@ -77,7 +82,8 @@ export function ModerationQueue() { rating, created_at, user_id, - moderation_status + moderation_status, + photos `) .in('moderation_status', reviewStatuses) .order('created_at', { ascending: false }); @@ -88,8 +94,8 @@ export function ModerationQueue() { // Fetch content submissions let submissions = []; - if (submissionStatuses.length > 0) { - const { data: submissionsData, error: submissionsError } = await supabase + if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) { + let query = supabase .from('content_submissions') .select(` id, @@ -99,7 +105,16 @@ export function ModerationQueue() { user_id, status `) - .in('status', submissionStatuses) + .in('status', submissionStatuses); + + // Filter by submission type for photos + if (entityFilter === 'photos') { + query = query.eq('submission_type', 'photo'); + } else if (entityFilter === 'submissions') { + query = query.neq('submission_type', 'photo'); + } + + const { data: submissionsData, error: submissionsError } = await query .order('created_at', { ascending: false }); if (submissionsError) throw submissionsError; @@ -138,6 +153,7 @@ export function ModerationQueue() { created_at: submission.created_at, user_id: submission.user_id, status: submission.status, + submission_type: submission.submission_type, user_profile: profileMap.get(submission.user_id), })), ]; @@ -159,8 +175,8 @@ export function ModerationQueue() { }; useEffect(() => { - fetchItems(activeFilter); - }, [activeFilter]); + fetchItems(activeEntityFilter, activeStatusFilter); + }, [activeEntityFilter, activeStatusFilter]); const handleModerationAction = async ( item: ModerationItem, @@ -194,11 +210,11 @@ export function ModerationQueue() { }); // Remove item from queue if it's no longer in the active filter - if (activeFilter === 'pending' || activeFilter === 'flagged') { + if (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') { setItems(prev => prev.filter(i => i.id !== item.id)); } else { // Refresh the queue to show updated status - fetchItems(activeFilter); + fetchItems(activeEntityFilter, activeStatusFilter); } // Clear notes @@ -234,20 +250,24 @@ export function ModerationQueue() { } }; - const getEmptyStateMessage = (filter: string) => { - switch (filter) { + const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter) => { + const entityLabel = entityFilter === 'all' ? 'items' : + entityFilter === 'reviews' ? 'reviews' : + entityFilter === 'photos' ? 'photos' : 'submissions'; + + switch (statusFilter) { case 'pending': - return 'No pending items require moderation at this time.'; + return `No pending ${entityLabel} require moderation at this time.`; case 'flagged': - return 'No flagged content found.'; + return `No flagged ${entityLabel} found.`; case 'approved': - return 'No approved content found.'; + return `No approved ${entityLabel} found.`; case 'rejected': - return 'No rejected content found.'; + return `No rejected ${entityLabel} found.`; case 'all': - return 'No moderation items found.'; + return `No ${entityLabel} found.`; default: - return 'No items found for the selected filter.'; + return `No ${entityLabel} found for the selected filter.`; } }; @@ -266,7 +286,7 @@ export function ModerationQueue() {

No items found

- {getEmptyStateMessage(activeFilter)} + {getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}

); @@ -285,7 +305,22 @@ export function ModerationQueue() {
- {item.type === 'review' ? 'Review' : 'Submission'} + {item.type === 'review' ? ( + <> + + Review + + ) : item.submission_type === 'photo' ? ( + <> + + Photo + + ) : ( + <> + + Submission + + )} {item.status.charAt(0).toUpperCase() + item.status.slice(1)} @@ -325,6 +360,47 @@ export function ModerationQueue() {
Rating: {item.content.rating}/5
+ {item.content.photos && item.content.photos.length > 0 && ( +
+
Attached Photos:
+
+ {item.content.photos.map((photo: any, index: number) => ( +
+ {`Review +
+ ))} +
+
+ )} +
+ ) : item.submission_type === 'photo' ? ( +
+
+ Photo Submission +
+ {item.content.content?.url && ( +
+ Submitted photo +
+ )} + {item.content.content?.caption && ( +
+
Caption:
+

{item.content.content.caption}

+
+ )} +
+
Filename: {item.content.content?.filename || 'Unknown'}
+
Size: {item.content.content?.size ? `${Math.round(item.content.content.size / 1024)} KB` : 'Unknown'}
+
) : (
@@ -380,33 +456,56 @@ export function ModerationQueue() { }; return ( - - - - - All - - - - Pending - - - - Flagged - - - - Approved - - - - Rejected - - +
+ {/* Entity Type Filter */} + setActiveEntityFilter(value as EntityFilter)}> + + + + All + + + + Reviews + + + + Submissions + + + + Photos + + + - - - - + {/* Status Filter */} + setActiveStatusFilter(value as StatusFilter)}> + + + + All + + + + Pending + + + + Flagged + + + + Approved + + + + Rejected + + + + + +
); } \ No newline at end of file diff --git a/src/components/upload/PhotoSubmissionUpload.tsx b/src/components/upload/PhotoSubmissionUpload.tsx new file mode 100644 index 00000000..c7214b46 --- /dev/null +++ b/src/components/upload/PhotoSubmissionUpload.tsx @@ -0,0 +1,217 @@ +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([]); + const [uploading, setUploading] = useState(false); + const [caption, setCaption] = useState(''); + const [title, setTitle] = useState(''); + const { toast } = useToast(); + const { user } = useAuth(); + + const handleFileSelect = (event: React.ChangeEvent) => { + 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 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 a temporary location or process them for moderation + const photoSubmissions = await Promise.all( + selectedFiles.map(async (file, index) => { + // Create a blob URL for preview + const url = URL.createObjectURL(file); + + return { + filename: file.name, + size: file.size, + type: file.type, + url, // In a real implementation, you'd upload to storage first + 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 ( + + +
+ +

Submit Photos

+
+ +
+
+ + setTitle(e.target.value)} + maxLength={100} + /> +
+ +
+ +