diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 146a87a3..6e57c936 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,24 +1,17 @@ import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef } from 'react'; -import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap } from 'lucide-react'; +import { CheckCircle, XCircle, Filter, MessageSquare, FileText, Image, X, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap } 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; -import { format, formatDistance } from 'date-fns'; +import { formatDistance } from 'date-fns'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { useIsMobile } from '@/hooks/use-mobile'; -import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; -import { SubmissionItemsList } from './SubmissionItemsList'; -import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueue } from '@/hooks/useModerationQueue'; import { Progress } from '@/components/ui/progress'; @@ -27,6 +20,7 @@ import { EscalationDialog } from './EscalationDialog'; import { ReassignDialog } from './ReassignDialog'; import { smartMergeArray } from '@/lib/smartStateUpdate'; import { useDebounce } from '@/hooks/useDebounce'; +import { QueueItem } from './QueueItem'; interface ModerationItem { id: string; @@ -816,10 +810,7 @@ export const ModerationQueue = forwardRef((props, ref) => { description: `Processed ${failedItems.length} failed item(s)`, }); - // Silent cleanup after delay - setTimeout(() => { - fetchItems(activeEntityFilter, activeStatusFilter, true); - }, 2000); + // No refresh needed - item already updated optimistically } catch (error: any) { console.error('Error retrying failed items:', error); toast({ @@ -1404,6 +1395,34 @@ export const ModerationQueue = forwardRef((props, ref) => { } }; + // Memoized callbacks + const handleNoteChange = useCallback((id: string, value: string) => { + setNotes(prev => ({ ...prev, [id]: value })); + }, []); + + const handleOpenPhotos = useCallback((photos: any[], index: number) => { + setSelectedPhotos(photos); + setSelectedPhotoIndex(index); + setPhotoModalOpen(true); + }, []); + + const handleOpenReviewManager = useCallback((id: string) => { + setSelectedSubmissionId(id); + setReviewManagerOpen(true); + }, []); + + const handleInteractionFocus = useCallback((id: string) => { + setInteractingWith(prev => new Set(prev).add(id)); + }, []); + + const handleInteractionBlur = useCallback((id: string) => { + setInteractingWith(prev => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + const QueueContent = () => { if (isInitialLoad && loading) { return ( @@ -1428,7 +1447,32 @@ export const ModerationQueue = forwardRef((props, ref) => { return (
{items.map((item) => ( - queue.claimSubmission(id)} + onDeleteSubmission={handleDeleteSubmission} + onInteractionFocus={handleInteractionFocus} + onInteractionBlur={handleInteractionBlur} + /> + ))} +
+ ); + }; key={item.id} className={`border-l-4 transition-opacity duration-200 ${ item.status === 'flagged' ? 'border-l-red-500' : @@ -2424,10 +2468,8 @@ export const ModerationQueue = forwardRef((props, ref) => { open={reviewManagerOpen} onOpenChange={setReviewManagerOpen} onComplete={() => { - // Silent cleanup after delay - setTimeout(() => { - fetchItems(activeEntityFilter, activeStatusFilter, true); - }, 2000); + // No refresh needed - item was removed optimistically + setReviewManagerOpen(false); }} /> )} diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx new file mode 100644 index 00000000..6ba3a7d2 --- /dev/null +++ b/src/components/moderation/QueueItem.tsx @@ -0,0 +1,637 @@ +import { memo } from 'react'; +import { CheckCircle, XCircle, Eye, Calendar, MessageSquare, FileText, Image, ListTree, RefreshCw, AlertCircle, Lock, Trash2, AlertTriangle } 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { format } from 'date-fns'; +import { SubmissionItemsList } from './SubmissionItemsList'; +import { MeasurementDisplay } from '@/components/ui/measurement-display'; + +interface ModerationItem { + id: string; + type: 'review' | 'content_submission'; + content: any; + created_at: string; + updated_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; + }; + escalated?: boolean; + assigned_to?: string; + locked_until?: string; +} + +interface QueueItemProps { + item: ModerationItem; + isMobile: boolean; + actionLoading: string | null; + lockedSubmissions: Set; + currentLockSubmissionId?: string; + notes: Record; + isAdmin: boolean; + isSuperuser: boolean; + queueIsLoading: boolean; + onNoteChange: (id: string, value: string) => void; + onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void; + onResetToPending: (item: ModerationItem) => void; + onRetryFailed: (item: ModerationItem) => void; + onOpenPhotos: (photos: any[], index: number) => void; + onOpenReviewManager: (submissionId: string) => void; + onClaimSubmission: (submissionId: string) => void; + onDeleteSubmission: (item: ModerationItem) => void; + onInteractionFocus: (id: string) => void; + onInteractionBlur: (id: string) => void; +} + +const getStatusBadgeVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => { + switch (status) { + case 'pending': return 'default'; + case 'approved': return 'secondary'; + case 'rejected': return 'destructive'; + case 'flagged': return 'destructive'; + case 'partially_approved': return 'outline'; + default: return 'outline'; + } +}; + +export const QueueItem = memo(({ + item, + isMobile, + actionLoading, + lockedSubmissions, + currentLockSubmissionId, + notes, + isAdmin, + isSuperuser, + queueIsLoading, + onNoteChange, + onApprove, + onResetToPending, + onRetryFailed, + onOpenPhotos, + onOpenReviewManager, + onClaimSubmission, + onDeleteSubmission, + onInteractionFocus, + onInteractionBlur +}: QueueItemProps) => { + return ( + + +
+
+ + {item.type === 'review' ? ( + <> + + Review + + ) : item.submission_type === 'photo' ? ( + <> + + Photo + + ) : ( + <> + + Submission + + )} + + + {item.status === 'partially_approved' ? 'Partially Approved' : + item.status.charAt(0).toUpperCase() + item.status.slice(1)} + + {item.status === 'partially_approved' && ( + + + Needs Retry + + )} + {lockedSubmissions.has(item.id) && item.type === 'content_submission' && ( + + + Locked by Another Moderator + + )} + {currentLockSubmissionId === item.id && item.type === 'content_submission' && ( + + + Claimed by You + + )} +
+
+ + {format(new Date(item.created_at), isMobile ? 'MMM d, yyyy' : '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) => ( +
{ + onOpenPhotos(item.content.photos.map((p: any, i: number) => ({ + id: `${item.id}-${i}`, + url: p.url, + filename: `Review photo ${i + 1}`, + caption: p.caption + })), index); + }}> + {`Review { + console.error('Failed to load review photo:', photo.url); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ +
+
+ ))} +
+
+ )} +
+ ) : 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) => ( +
+
{ + onOpenPhotos(item.content.content.photos.map((p: any, i: number) => ({ + id: `${item.id}-${i}`, + url: p.url, + filename: p.filename, + caption: p.caption + })), index); + }}> + {`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) { + // Create elements safely using DOM API to prevent XSS + const errorContainer = document.createElement('div'); + errorContainer.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs'; + + const errorIcon = document.createElement('div'); + errorIcon.textContent = '⚠️ Image failed to load'; + + const urlDisplay = document.createElement('div'); + urlDisplay.className = 'mt-1 font-mono text-xs break-all px-2'; + // Use textContent to prevent XSS - it escapes HTML automatically + urlDisplay.textContent = photo.url; + + errorContainer.appendChild(errorIcon); + errorContainer.appendChild(urlDisplay); + parent.appendChild(errorContainer); + } + }} + /> +
+ +
+
+
+
+ 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} +
+ )} +
+ )} +
+ ) : ( +
+ {/* Composite Submissions or Standard Submissions */} + +
+ )} +
+ + {/* Action buttons based on status */} + {(item.status === 'pending' || item.status === 'flagged') && ( + <> + {/* Claim button for unclaimed submissions */} + {!lockedSubmissions.has(item.id) && currentLockSubmissionId !== item.id && ( +
+ + + Unclaimed Submission + +
+ Claim this submission to lock it for 15 minutes while you review + +
+
+
+
+ )} + +
+ +