import { useState, useEffect, useImperativeHandle, forwardRef } from 'react'; import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { format } from 'date-fns'; import { PhotoModal } from './PhotoModal'; interface ModerationItem { id: string; type: 'review' | 'content_submission'; content: any; created_at: string; user_id: string; status: string; submission_type?: string; user_profile?: { username: string; display_name?: string; avatar_url?: string; }; entity_name?: string; park_name?: string; reviewed_at?: string; reviewed_by?: string; reviewer_notes?: string; reviewer_profile?: { username: string; display_name?: string; avatar_url?: string; }; } type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type StatusFilter = 'all' | 'pending' | 'flagged' | 'approved' | 'rejected'; export interface ModerationQueueRef { refresh: () => void; } export const ModerationQueue = forwardRef((props, ref) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); const [activeEntityFilter, setActiveEntityFilter] = useState('all'); const [activeStatusFilter, setActiveStatusFilter] = useState('pending'); const [photoModalOpen, setPhotoModalOpen] = useState(false); const [selectedPhotos, setSelectedPhotos] = useState([]); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); // Expose refresh method via ref useImperativeHandle(ref, () => ({ refresh: () => { fetchItems(activeEntityFilter, activeStatusFilter); } }), [activeEntityFilter, activeStatusFilter]); const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => { try { setLoading(true); let reviewStatuses: string[] = []; let submissionStatuses: string[] = []; // Define status filters switch (statusFilter) { case 'all': reviewStatuses = ['pending', 'flagged', 'approved', 'rejected']; submissionStatuses = ['pending', 'approved', 'rejected']; break; case 'pending': reviewStatuses = ['pending']; submissionStatuses = ['pending']; break; case 'flagged': reviewStatuses = ['flagged']; submissionStatuses = []; // Content submissions don't have flagged status break; case 'approved': reviewStatuses = ['approved']; submissionStatuses = ['approved']; break; case 'rejected': reviewStatuses = ['rejected']; submissionStatuses = ['rejected']; break; default: reviewStatuses = ['pending', 'flagged']; submissionStatuses = ['pending']; } // Fetch reviews with entity data let reviews = []; if ((entityFilter === 'all' || entityFilter === 'reviews') && reviewStatuses.length > 0) { const { data: reviewsData, error: reviewsError } = await supabase .from('reviews') .select(` id, title, content, rating, created_at, user_id, moderation_status, photos, park_id, ride_id, moderated_at, moderated_by, parks:park_id ( name ), rides:ride_id ( name, parks:park_id ( name ) ) `) .in('moderation_status', reviewStatuses) .order('created_at', { ascending: false }); if (reviewsError) throw reviewsError; reviews = reviewsData || []; } // Fetch content submissions with entity data let submissions = []; if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) { let query = supabase .from('content_submissions') .select(` id, content, submission_type, created_at, user_id, status, reviewed_at, reviewer_id, reviewer_notes `) .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; // Get entity data for photo submissions let submissionsWithEntities = submissionsData || []; for (const submission of submissionsWithEntities) { if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') { const contentObj = submission.content as any; // Handle both old format (context as object) and new format (context as string) let contextType = null; let rideId = null; let parkId = null; if (typeof contentObj.context === 'object' && contentObj.context !== null) { // OLD FORMAT: context is an object like {ride_id: "...", park_id: "..."} rideId = contentObj.context.ride_id; parkId = contentObj.context.park_id; contextType = rideId ? 'ride' : parkId ? 'park' : null; } else if (typeof contentObj.context === 'string') { // NEW FORMAT: context is a string, IDs are at top level contextType = contentObj.context; rideId = contentObj.ride_id; parkId = contentObj.park_id; } if (contextType === 'ride' && rideId) { const { data: rideData } = await supabase .from('rides') .select(` name, parks:park_id ( name ) `) .eq('id', rideId) .single(); if (rideData) { (submission as any).entity_name = rideData.name; (submission as any).park_name = rideData.parks?.name; } } else if (contextType === 'park' && parkId) { const { data: parkData } = await supabase .from('parks') .select('name') .eq('id', parkId) .single(); if (parkData) { (submission as any).entity_name = parkData.name; } } } } submissions = submissionsWithEntities; } // Get unique user IDs to fetch profiles (including reviewers) const userIds = [ ...reviews.map(r => r.user_id), ...submissions.map(s => s.user_id), ...reviews.filter(r => r.moderated_by).map(r => r.moderated_by), ...submissions.filter(s => s.reviewer_id).map(s => s.reviewer_id) ].filter((id, index, arr) => id && arr.indexOf(id) === index); // Remove duplicates and nulls // Fetch profiles for all users with avatars const { data: profiles } = await supabase .from('profiles') .select('user_id, username, display_name, avatar_url') .in('user_id', userIds); const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); // Combine and format items const formattedItems: ModerationItem[] = [ ...reviews.map(review => { let entity_name = ''; let park_name = ''; if ((review as any).rides) { entity_name = (review as any).rides.name; park_name = (review as any).rides.parks?.name; } else if ((review as any).parks) { entity_name = (review as any).parks.name; } return { id: review.id, type: 'review' as const, content: review, created_at: review.created_at, user_id: review.user_id, status: review.moderation_status, user_profile: profileMap.get(review.user_id), entity_name, park_name, reviewed_at: review.moderated_at, reviewed_by: review.moderated_by, reviewer_notes: (review as any).reviewer_notes, reviewer_profile: review.moderated_by ? profileMap.get(review.moderated_by) : undefined, }; }), ...submissions.map(submission => ({ id: submission.id, type: 'content_submission' as const, content: submission, 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), entity_name: (submission as any).entity_name, park_name: (submission as any).park_name, reviewed_at: submission.reviewed_at, reviewed_by: submission.reviewer_id, reviewer_notes: submission.reviewer_notes, reviewer_profile: submission.reviewer_id ? profileMap.get(submission.reviewer_id) : undefined, })), ]; // Sort by creation date (newest first for better UX) formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); console.log('Formatted items:', formattedItems); console.log('Photo submissions:', formattedItems.filter(item => item.submission_type === 'photo')); setItems(formattedItems); } catch (error) { console.error('Error fetching moderation items:', error); toast({ title: "Error", description: "Failed to load moderation queue", variant: "destructive", }); } finally { setLoading(false); } }; useEffect(() => { fetchItems(activeEntityFilter, activeStatusFilter); }, [activeEntityFilter, activeStatusFilter]); const handleModerationAction = async ( item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string ) => { // Prevent multiple clicks on the same item if (actionLoading === item.id) { console.log('Action already in progress for item:', item.id); return; } setActionLoading(item.id); try { const table = item.type === 'review' ? 'reviews' : 'content_submissions'; const statusField = item.type === 'review' ? 'moderation_status' : 'status'; // Use correct timestamp column name based on table const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at'; const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id'; const updateData: any = { [statusField]: action, [timestampField]: new Date().toISOString(), }; // Get current user ID for reviewer tracking const { data: { user } } = await supabase.auth.getUser(); if (user) { updateData[reviewerField] = user.id; } if (moderatorNotes) { updateData.reviewer_notes = moderatorNotes; } console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table); const { error, data } = await supabase .from(table) .update(updateData) .eq('id', item.id) .select(); if (error) { console.error('Database update error:', error); throw error; } console.log('Update response:', { data, rowsAffected: data?.length }); // Check if the update actually affected any rows if (!data || data.length === 0) { console.error('No rows were updated. This might be due to RLS policies or the item not existing.'); throw new Error('Failed to update item - no rows affected. You might not have permission to moderate this content.'); } console.log('Update successful, rows affected:', data.length); toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, }); // Only update local state if the database update was successful setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: action } : i )); // Clear notes only after successful update setNotes(prev => { const newNotes = { ...prev }; delete newNotes[item.id]; return newNotes; }); // Only refresh if we're viewing a filter that should no longer show this item if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) || (activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) { console.log('Item no longer matches filter, removing from view'); } } catch (error: any) { console.error('Error moderating content:', error); // Revert any optimistic updates setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: item.status } // Revert to original status : i )); toast({ title: "Error", description: error.message || `Failed to ${action} content`, variant: "destructive", }); } finally { setActionLoading(null); } }; const handleDeleteSubmission = async (item: ModerationItem) => { if (item.type !== 'content_submission') return; // Prevent duplicate calls if (actionLoading === item.id) { console.log('Deletion already in progress for:', item.id); return; } setActionLoading(item.id); // Remove item from UI immediately to prevent flickering setItems(prev => prev.filter(i => i.id !== item.id)); try { console.log('Starting deletion process for submission:', item.id); // Step 1: Extract photo IDs from the submission content const photoIds: string[] = []; const validImageIds: string[] = []; const skippedPhotos: string[] = []; // Try both nested paths for photos array (handle different content structures) const photosArray = item.content?.content?.photos || item.content?.photos; if (photosArray && Array.isArray(photosArray)) { console.log('Processing photos from content:', photosArray); for (const photo of photosArray) { console.log('Processing photo object:', photo); console.log('Photo keys:', Object.keys(photo)); console.log('photo.imageId:', photo.imageId, 'type:', typeof photo.imageId); let imageId = ''; // First try to use the stored imageId directly if (photo.imageId) { imageId = photo.imageId; console.log('Using stored image ID:', imageId); } else if (photo.url) { // Check if this looks like a Cloudflare image ID (not a blob URL) if (photo.url.startsWith('blob:')) { // This is a blob URL - we can't extract a valid Cloudflare image ID console.warn('Skipping blob URL (cannot extract Cloudflare image ID):', photo.url); skippedPhotos.push(photo.url); continue; } // Fallback: Try to extract from URL for backward compatibility const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; if (uuidRegex.test(photo.url)) { imageId = photo.url; } else { // Extract from Cloudflare image delivery URL format const cloudflareMatch = photo.url.match(/imagedelivery\.net\/[^\/]+\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i); if (cloudflareMatch) { imageId = cloudflareMatch[1]; } } console.log('Extracted image ID from URL:', imageId, 'from URL:', photo.url); } if (imageId) { photoIds.push(imageId); validImageIds.push(imageId); } else { console.warn('Could not get valid image ID from photo:', photo); skippedPhotos.push(photo.url || 'unknown'); } } } console.log(`Found ${validImageIds.length} valid image IDs to delete, ${skippedPhotos.length} photos will be orphaned`); // Step 2: Delete photos from Cloudflare Images (if any valid IDs) if (validImageIds.length > 0) { const deletePromises = validImageIds.map(async (imageId) => { try { console.log('Attempting to delete image from Cloudflare:', imageId); // Direct fetch call to the edge function with proper DELETE method const response = await fetch('https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4` }, body: JSON.stringify({ imageId }) }); if (!response.ok) { const errorData = await response.text(); throw new Error(`HTTP ${response.status}: ${errorData}`); } const result = await response.json(); console.log('Successfully deleted image:', imageId, result); } catch (deleteError) { console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError); // Continue with other deletions - don't fail the entire operation } }); // Execute all photo deletions in parallel await Promise.allSettled(deletePromises); } // Step 3: Delete the submission from the database console.log('Deleting submission from database:', item.id); const { error } = await supabase .from('content_submissions') .delete() .eq('id', item.id); if (error) { console.error('Database deletion error:', error); throw error; } // Verify the deletion actually worked const { data: checkData, error: checkError } = await supabase .from('content_submissions') .select('id') .eq('id', item.id) .single(); if (checkData && !checkError) { console.error('DELETION FAILED: Item still exists in database after delete operation'); throw new Error('Deletion failed - item still exists in database'); } else { console.log('Verified: Submission successfully deleted from database'); } const deletedCount = validImageIds.length; const orphanedCount = skippedPhotos.length; let description = 'The submission has been permanently deleted'; if (deletedCount > 0 && orphanedCount > 0) { description = `The submission and ${deletedCount} photo(s) have been deleted. ${orphanedCount} photo(s) could not be deleted from storage (orphaned blob URLs)`; } else if (deletedCount > 0) { description = `The submission and ${deletedCount} associated photo(s) have been permanently deleted`; } else if (orphanedCount > 0) { description = `The submission has been deleted. ${orphanedCount} photo(s) could not be deleted from storage (orphaned blob URLs)`; } toast({ title: "Submission deleted", description, }); // Remove item from the current view // Item was already removed at the start for immediate UI feedback } catch (error) { console.error('Error deleting submission:', error); // Restore item to list on error since we removed it optimistically setItems(prev => { // Avoid duplicates if (prev.some(i => i.id === item.id)) return prev; return [...prev, item]; }); toast({ title: "Error", description: "Failed to delete submission", variant: "destructive", }); } finally { setActionLoading(null); } }; const getStatusBadgeVariant = (status: string) => { switch (status) { case 'pending': return 'secondary'; case 'flagged': return 'destructive'; case 'approved': return 'default'; case 'rejected': return 'destructive'; default: return 'secondary'; } }; 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 ${entityLabel} require moderation at this time.`; case 'flagged': return `No flagged ${entityLabel} found.`; case 'approved': return `No approved ${entityLabel} found.`; case 'rejected': return `No rejected ${entityLabel} found.`; case 'all': return `No ${entityLabel} found.`; default: return `No ${entityLabel} found for the selected filter.`; } }; const QueueContent = () => { if (loading) { return (
); } if (items.length === 0) { return (

No items found

{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}

); } return (
{items.map((item) => (
{item.type === 'review' ? ( <> Review ) : item.submission_type === 'photo' ? ( <> Photo ) : ( <> Submission )} {item.status.charAt(0).toUpperCase() + item.status.slice(1)}
{format(new Date(item.created_at), 'MMM d, yyyy HH:mm')}
{item.user_profile && (
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
{item.user_profile.display_name || item.user_profile.username} {item.user_profile.display_name && ( @{item.user_profile.username} )}
)}
{item.type === 'review' ? (
{item.content.title && (

{item.content.title}

)} {item.content.content && (

{item.content.content}

)}
Rating: {item.content.rating}/5
{/* Entity Names for Reviews */} {(item.entity_name || item.park_name) && (
{item.entity_name && (
{item.park_name ? 'Ride:' : 'Park:'} {item.entity_name}
)} {item.park_name && (
Park: {item.park_name}
)}
)} {item.content.photos && item.content.photos.length > 0 && (
Attached Photos:
{item.content.photos.map((photo: any, index: number) => (
{ setSelectedPhotos(item.content.photos.map((p: any, i: number) => ({ id: `${item.id}-${i}`, url: p.url, filename: `Review photo ${i + 1}`, caption: p.caption }))); setSelectedPhotoIndex(index); setPhotoModalOpen(true); }}> {`Review { console.error('Failed to load review photo:', photo.url); (e.target as HTMLImageElement).style.display = 'none'; }} onLoad={() => console.log('Review photo loaded:', photo.url)} />
))}
)}
) : item.submission_type === 'photo' ? (
Photo Submission
{/* Submission Title */} {item.content.title && (
Title:

{item.content.title}

)} {/* Submission Caption */} {item.content.content?.caption && (
Caption:

{item.content.content.caption}

)} {/* Photos */} {item.content.content?.photos && item.content.content.photos.length > 0 ? (
Photos ({item.content.content.photos.length}):
{item.content.content.photos.map((photo: any, index: number) => (
{ setSelectedPhotos(item.content.content.photos.map((p: any, i: number) => ({ id: `${item.id}-${i}`, url: p.url, filename: p.filename, caption: p.caption }))); setSelectedPhotoIndex(index); setPhotoModalOpen(true); }}> {`Photo { console.error('Failed to load photo submission:', photo); const target = e.target as HTMLImageElement; target.style.display = 'none'; const parent = target.parentElement; if (parent) { parent.innerHTML = `
⚠️ Image failed to load
${photo.url}
`; } }} onLoad={() => console.log('Photo submission loaded:', photo.url)} />
URL: {photo.url}
Filename: {photo.filename || 'Unknown'}
Size: {photo.size ? `${Math.round(photo.size / 1024)} KB` : 'Unknown'}
Type: {photo.type || 'Unknown'}
{photo.caption && (
Caption:
{photo.caption}
)}
))}
) : (
No photos found in submission
)} {/* Context Information */} {item.content.content?.context && (
Context: {typeof item.content.content.context === 'object' ? (item.content.content.context.ride_id ? 'ride' : item.content.content.context.park_id ? 'park' : 'unknown') : item.content.content.context}
{item.entity_name && (
{(typeof item.content.content.context === 'object' ? (item.content.content.context.ride_id ? 'ride' : 'park') : item.content.content.context) === 'ride' ? 'Ride:' : 'Park:'} {item.entity_name}
)} {item.park_name && (typeof item.content.content.context === 'object' ? !!item.content.content.context.ride_id : item.content.content.context === 'ride') && (
Park: {item.park_name}
)}
)}
) : (
Type: {item.content.submission_type}
                      {JSON.stringify(item.content.content, null, 2)}
                    
)}
{/* Action buttons based on status */} {(item.status === 'pending' || item.status === 'flagged') && ( <>