import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef, useMemo } from 'react'; import { CheckCircle, XCircle, Filter, MessageSquare, FileText, Image, X, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap, ArrowUp, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '@/components/ui/pagination'; 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 { formatDistance } from 'date-fns'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { useIsMobile } from '@/hooks/use-mobile'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueue } from '@/hooks/useModerationQueue'; import { Progress } from '@/components/ui/progress'; import { QueueStatsDashboard } from './QueueStatsDashboard'; import { EscalationDialog } from './EscalationDialog'; import { ReassignDialog } from './ReassignDialog'; import { smartMergeArray } from '@/lib/smartStateUpdate'; import { useDebounce } from '@/hooks/useDebounce'; import { QueueItem } from './QueueItem'; import { QueueSkeleton } from './QueueSkeleton'; 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; _removing?: boolean; submission_items?: Array<{ id: string; item_type: string; item_data: any; status: string; }>; } type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' | 'approved' | 'rejected'; type QueueTab = 'mainQueue' | 'archive'; type SortField = 'created_at' | 'username' | 'submission_type' | 'status' | 'escalated'; type SortDirection = 'asc' | 'desc'; interface SortConfig { field: SortField; direction: SortDirection; } export interface ModerationQueueRef { refresh: () => void; } export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); const [items, setItems] = useState([]); const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'refreshing' | 'ready'>('initial'); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); const [activeTab, setActiveTab] = useState('mainQueue'); const [hasRenderedOnce, setHasRenderedOnce] = useState(false); 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 [reviewManagerOpen, setReviewManagerOpen] = useState(false); const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); const [lockedSubmissions, setLockedSubmissions] = useState>(new Set()); const [escalationDialogOpen, setEscalationDialogOpen] = useState(false); const [reassignDialogOpen, setReassignDialogOpen] = useState(false); const [selectedItemForAction, setSelectedItemForAction] = useState(null); const [interactingWith, setInteractingWith] = useState>(new Set()); const [newItemsCount, setNewItemsCount] = useState(0); const [profileCache, setProfileCache] = useState>(new Map()); const [entityCache, setEntityCache] = useState<{ rides: Map, parks: Map, companies: Map }>({ rides: new Map(), parks: new Map(), companies: new Map() }); const [submissionMemo, setSubmissionMemo] = useState>(new Map()); const [pendingNewItems, setPendingNewItems] = useState([]); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); const queue = useModerationQueue(); const fetchInProgressRef = useRef(false); const itemsRef = useRef([]); const loadedIdsRef = useRef>(new Set()); const realtimeUpdateDebounceRef = useRef>(new Map()); const lastFetchTimeRef = useRef(0); const isMountingRef = useRef(true); const initialFetchCompleteRef = useRef(false); const FETCH_COOLDOWN_MS = 1000; // Pagination state const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [totalCount, setTotalCount] = useState(0); const totalPages = Math.ceil(totalCount / pageSize); // Sort state const [sortConfig, setSortConfig] = useState(() => { const saved = localStorage.getItem('moderationQueue_sortConfig'); if (saved) { try { return JSON.parse(saved); } catch { return { field: 'created_at', direction: 'asc' as SortDirection }; } } return { field: 'created_at', direction: 'asc' as SortDirection }; }); // Get admin settings for polling configuration const { getAdminPanelRefreshMode, getAdminPanelPollInterval, getAutoRefreshStrategy, getPreserveInteractionState, getUseRealtimeQueue } = useAdminSettings(); const refreshMode = getAdminPanelRefreshMode(); const pollInterval = getAdminPanelPollInterval(); const refreshStrategy = getAutoRefreshStrategy(); const preserveInteraction = getPreserveInteractionState(); const useRealtimeQueue = getUseRealtimeQueue(); // Track recently removed items to prevent realtime override of optimistic updates const recentlyRemovedRef = useRef>(new Set()); const prevLocksRef = useRef>(new Map()); // Store admin settings and stable refs to avoid triggering fetchItems recreation const refreshStrategyRef = useRef(refreshStrategy); const preserveInteractionRef = useRef(preserveInteraction); const userRef = useRef(user); const toastRef = useRef(toast); const isAdminRef = useRef(isAdmin); const isSuperuserRef = useRef(isSuperuser); useEffect(() => { refreshStrategyRef.current = refreshStrategy; preserveInteractionRef.current = preserveInteraction; userRef.current = user; toastRef.current = toast; isAdminRef.current = isAdmin; isSuperuserRef.current = isSuperuser; }, [refreshStrategy, preserveInteraction, user, toast, isAdmin, isSuperuser]); // Persist sort configuration useEffect(() => { localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig)); }, [sortConfig]); // Sync itemsRef with items state (after React commits) useEffect(() => { itemsRef.current = items; }, [items]); // Sync loadedIdsRef with items state (after React commits) useEffect(() => { loadedIdsRef.current = new Set(items.map(item => item.id)); }, [items]); // Enable transitions after initial render useEffect(() => { if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) { // Use requestAnimationFrame to enable transitions AFTER first paint requestAnimationFrame(() => { requestAnimationFrame(() => { setHasRenderedOnce(true); }); }); } }, [loadingState, items.length, hasRenderedOnce]); const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false, tab: QueueTab = 'mainQueue') => { if (!userRef.current) { return; } // Prevent concurrent calls - race condition guard if (fetchInProgressRef.current) { console.log('⚠️ Fetch already in progress, skipping duplicate call'); return; } // Cooldown check - prevent rapid-fire calls const now = Date.now(); const timeSinceLastFetch = now - lastFetchTimeRef.current; if (timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) { console.log(`⏸️ Fetch cooldown active (${timeSinceLastFetch}ms since last fetch), skipping`); return; } fetchInProgressRef.current = true; lastFetchTimeRef.current = now; console.log('πŸ” fetchItems called:', { entityFilter, statusFilter, tab, silent, timestamp: new Date().toISOString(), caller: new Error().stack?.split('\n')[2]?.trim() }); try { // Set loading states if (!silent) { setLoadingState('loading'); } else { setLoadingState('refreshing'); } // Build base query for content submissions let submissionsQuery = supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, submission_items ( id, item_type, item_data, status ) `) .order('escalated', { ascending: false }) .order('created_at', { ascending: true }); // Apply tab-based status filtering if (tab === 'mainQueue') { // Main queue: pending, flagged, partially_approved submissions if (statusFilter === 'all') { submissionsQuery = submissionsQuery.in('status', ['pending', 'flagged', 'partially_approved']); } else if (statusFilter === 'pending') { submissionsQuery = submissionsQuery.in('status', ['pending', 'partially_approved']); } else { submissionsQuery = submissionsQuery.eq('status', statusFilter); } } else { // Archive: approved or rejected submissions if (statusFilter === 'all') { submissionsQuery = submissionsQuery.in('status', ['approved', 'rejected']); } else { submissionsQuery = submissionsQuery.eq('status', statusFilter); } } // Apply entity type filter if (entityFilter === 'photos') { submissionsQuery = submissionsQuery.eq('submission_type', 'photo'); } else if (entityFilter === 'submissions') { submissionsQuery = submissionsQuery.neq('submission_type', 'photo'); } // CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions // Admins see all submissions if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); submissionsQuery = submissionsQuery.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}` ); } // Get total count for pagination const countQuery = supabase .from('content_submissions') .select('*', { count: 'exact', head: true }); // Apply same filters to count query let countQueryWithFilters = countQuery; if (tab === 'mainQueue') { if (statusFilter === 'all') { countQueryWithFilters = countQueryWithFilters.in('status', ['pending', 'flagged', 'partially_approved']); } else if (statusFilter === 'pending') { countQueryWithFilters = countQueryWithFilters.in('status', ['pending', 'partially_approved']); } else { countQueryWithFilters = countQueryWithFilters.eq('status', statusFilter); } } else { if (statusFilter === 'all') { countQueryWithFilters = countQueryWithFilters.in('status', ['approved', 'rejected']); } else { countQueryWithFilters = countQueryWithFilters.eq('status', statusFilter); } } if (entityFilter === 'photos') { countQueryWithFilters = countQueryWithFilters.eq('submission_type', 'photo'); } else if (entityFilter === 'submissions') { countQueryWithFilters = countQueryWithFilters.neq('submission_type', 'photo'); } if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); countQueryWithFilters = countQueryWithFilters.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}` ); } const { count } = await countQueryWithFilters; setTotalCount(count || 0); // Apply pagination range const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize - 1; submissionsQuery = submissionsQuery.range(startIndex, endIndex); const { data: submissions, error: submissionsError } = await submissionsQuery; if (submissionsError) throw submissionsError; // Get user IDs and fetch user profiles const userIds = submissions?.map(s => s.user_id).filter(Boolean) || []; const reviewerIds = submissions?.map(s => s.reviewer_id).filter((id): id is string => !!id) || []; const allUserIds = [...new Set([...userIds, ...reviewerIds])]; let userProfiles: any[] = []; if (allUserIds.length > 0) { const { data: profiles } = await supabase .from('profiles') .select('user_id, username, display_name, avatar_url') .in('user_id', allUserIds); userProfiles = profiles || []; } const userProfileMap = new Map(userProfiles.map(p => [p.user_id, p])); // Collect entity IDs for bulk fetching const rideIds = new Set(); const parkIds = new Set(); const companyIds = new Set(); const rideModelIds = new Set(); submissions?.forEach(submission => { const content = submission.content as any; if (content && typeof content === 'object') { if (content.ride_id) rideIds.add(content.ride_id); if (content.park_id) parkIds.add(content.park_id); if (content.company_id) companyIds.add(content.company_id); if (content.entity_id) { if (submission.submission_type === 'ride') rideIds.add(content.entity_id); if (submission.submission_type === 'park') parkIds.add(content.entity_id); if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type)) { companyIds.add(content.entity_id); } } if (content.manufacturer_id) companyIds.add(content.manufacturer_id); if (content.designer_id) companyIds.add(content.designer_id); if (content.operator_id) companyIds.add(content.operator_id); if (content.property_owner_id) companyIds.add(content.property_owner_id); if (content.ride_model_id) rideModelIds.add(content.ride_model_id); } }); // Fetch entities only if we don't have them cached or if they're new const fetchPromises: Promise<{ type: string; data: any[] }>[] = []; if (rideIds.size > 0) { const uncachedRideIds = Array.from(rideIds).filter(id => !entityCache.rides.has(id)); if (uncachedRideIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('rides') .select('id, name, park_id') .in('id', uncachedRideIds) ).then(({ data }) => ({ type: 'rides', data: data || [] })) ); } } if (parkIds.size > 0) { const uncachedParkIds = Array.from(parkIds).filter(id => !entityCache.parks.has(id)); if (uncachedParkIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('parks') .select('id, name') .in('id', uncachedParkIds) ).then(({ data }) => ({ type: 'parks', data: data || [] })) ); } } if (companyIds.size > 0) { const uncachedCompanyIds = Array.from(companyIds).filter(id => !entityCache.companies.has(id)); if (uncachedCompanyIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('companies') .select('id, name') .in('id', uncachedCompanyIds) ).then(({ data }) => ({ type: 'companies', data: data || [] })) ); } } // Fetch all uncached entities const entityResults = await Promise.all(fetchPromises); // Update entity cache entityResults.forEach(result => { if (result.type === 'rides') { result.data.forEach((ride: any) => { entityCache.rides.set(ride.id, ride); if (ride.park_id) parkIds.add(ride.park_id); }); } else if (result.type === 'parks') { result.data.forEach((park: any) => { entityCache.parks.set(park.id, park); }); } else if (result.type === 'companies') { result.data.forEach((company: any) => { entityCache.companies.set(company.id, company); }); } }); // Helper function to create memo key const createMemoKey = (submission: any): string => { return JSON.stringify({ id: submission.id, status: submission.status, content: submission.content, reviewed_at: submission.reviewed_at, reviewer_notes: submission.reviewer_notes, }); }; // Map submissions to moderation items with memoization const moderationItems: ModerationItem[] = submissions?.map(submission => { const memoKey = createMemoKey(submission); const existingMemo = submissionMemo.get(submission.id); // Check if we can reuse the memoized item if (existingMemo && createMemoKey(existingMemo) === memoKey) { return existingMemo as ModerationItem; } // Resolve entity name const content = submission.content as any; let entityName = content?.name || 'Unknown'; let parkName: string | undefined; if (submission.submission_type === 'ride' && content?.entity_id) { const ride = entityCache.rides.get(content.entity_id); if (ride) { entityName = ride.name; if (ride.park_id) { const park = entityCache.parks.get(ride.park_id); if (park) parkName = park.name; } } } else if (submission.submission_type === 'park' && content?.entity_id) { const park = entityCache.parks.get(content.entity_id); if (park) entityName = park.name; } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) { const company = entityCache.companies.get(content.entity_id); if (company) entityName = company.name; } else if (content?.ride_id) { const ride = entityCache.rides.get(content.ride_id); if (ride) { entityName = ride.name; if (ride.park_id) { const park = entityCache.parks.get(ride.park_id); if (park) parkName = park.name; } } } else if (content?.park_id) { const park = entityCache.parks.get(content.park_id); if (park) parkName = park.name; } const userProfile = userProfileMap.get(submission.user_id); const reviewerProfile = submission.reviewer_id ? userProfileMap.get(submission.reviewer_id) : undefined; const item: ModerationItem = { id: submission.id, type: 'content_submission', content: submission.content, created_at: submission.created_at, user_id: submission.user_id, status: submission.status, submission_type: submission.submission_type, user_profile: userProfile ? { username: userProfile.username, display_name: userProfile.display_name, avatar_url: userProfile.avatar_url, } : undefined, entity_name: entityName, park_name: parkName, reviewed_at: submission.reviewed_at, reviewed_by: submission.reviewer_id, reviewer_notes: submission.reviewer_notes, reviewer_profile: reviewerProfile, }; return item; }) || []; // Update memoization cache const newMemoMap = new Map(); moderationItems.forEach(item => { newMemoMap.set(item.id, item); }); setSubmissionMemo(newMemoMap); // CRM-style frozen queue logic using admin settings const currentRefreshStrategy = refreshStrategyRef.current; const currentPreserveInteraction = preserveInteractionRef.current; if (silent) { // Background polling: detect new submissions by comparing IDs // Use currently DISPLAYED items (itemsRef) not loadedIdsRef to avoid false positives const currentDisplayedIds = new Set(itemsRef.current.map(item => item.id)); const newSubmissions = moderationItems.filter(item => !currentDisplayedIds.has(item.id)); if (newSubmissions.length > 0) { console.log('πŸ†• Detected new submissions:', newSubmissions.length); // Check against existing pendingNewItems to avoid double-counting setPendingNewItems(prev => { const existingIds = new Set(prev.map(p => p.id)); const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id)); // Track count increment (loadedIdsRef will sync automatically via useEffect) if (uniqueNew.length > 0) { setNewItemsCount(prev => prev + uniqueNew.length); } return [...prev, ...uniqueNew]; }); } // Apply refresh strategy from admin settings switch (currentRefreshStrategy) { case 'notify': // Only show notification count, never modify queue console.log('βœ… Queue frozen (notify mode) - existing submissions unchanged'); break; case 'merge': // Only add submissions that are genuinely NEW (not in current queue) if (newSubmissions.length > 0) { const currentIds = new Set(itemsRef.current.map(item => item.id)); const trulyNewSubmissions = newSubmissions.filter(item => !currentIds.has(item.id)); if (trulyNewSubmissions.length > 0) { setItems(prev => [...prev, ...trulyNewSubmissions]); console.log('πŸ”€ Queue merged - added', trulyNewSubmissions.length, 'truly new items'); } else { console.log('βœ… No new items - existing submissions unchanged'); } } else { console.log('βœ… Queue frozen (merge mode) - no new items to add'); } break; case 'replace': // Smart refresh: only update if data actually changed const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'], preserveOrder: false, addToTop: false, }); if (mergeResult.hasChanges) { setItems(mergeResult.items); console.log('πŸ”„ Queue updated (replace mode):', { added: mergeResult.changes.added.length, removed: mergeResult.changes.removed.length, updated: mergeResult.changes.updated.length, }); } else { console.log('βœ… Queue unchanged (replace mode) - no visual updates needed'); } if (!currentPreserveInteraction) { setPendingNewItems([]); setNewItemsCount(0); } break; default: // Fallback to frozen behavior console.log('βœ… Queue frozen (default) - existing submissions unchanged'); } } else { // Manual refresh: Use smart comparison even for non-silent refreshes console.log('πŸ”„ Manual refresh - checking for changes'); console.log('πŸ“Š Before merge - itemsRef.current has', itemsRef.current.length, 'items'); console.log('πŸ“Š New data from DB has', moderationItems.length, 'items'); const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'], preserveOrder: false, addToTop: false, }); console.log('πŸ“Š Merge result:', { hasChanges: mergeResult.hasChanges, added: mergeResult.changes.added.length, removed: mergeResult.changes.removed.length, updated: mergeResult.changes.updated.length, finalCount: mergeResult.items.length, }); if (mergeResult.hasChanges) { setItems(mergeResult.items); console.log('πŸ”„ Queue updated (manual refresh):', { added: mergeResult.changes.added.length, removed: mergeResult.changes.removed.length, updated: mergeResult.changes.updated.length, }); } else { console.log('βœ… Queue unchanged (manual refresh) - data identical, no re-render'); } // Always reset pending items on manual refresh setPendingNewItems([]); setNewItemsCount(0); // Initialize loadedIdsRef on first load or manual refresh if (loadedIdsRef.current.size === 0 || !silent) { loadedIdsRef.current = new Set(moderationItems.map(item => item.id)); console.log('πŸ“‹ Queue loaded - tracking', loadedIdsRef.current.size, 'submissions'); } } } catch (error: any) { console.error('Error fetching moderation items:', error); toast({ title: 'Error', description: error.message || 'Failed to fetch moderation queue', variant: 'destructive', }); } finally { fetchInProgressRef.current = false; setLoadingState('ready'); } }, []); // Empty deps - use refs instead // Debounced filters to prevent rapid-fire calls const debouncedEntityFilter = useDebounce(activeEntityFilter, 300); const debouncedStatusFilter = useDebounce(activeStatusFilter, 300); // Store latest filter values in ref to avoid dependency issues const filtersRef = useRef({ entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }); useEffect(() => { filtersRef.current = { entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }; }, [debouncedEntityFilter, debouncedStatusFilter]); // Expose refresh method via ref useImperativeHandle(ref, () => ({ refresh: () => { if (isMountingRef.current) { console.log('⏭️ Ignoring refresh during mount phase'); return; } fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, false); } }), []); // Track if initial fetch has happened const hasInitialFetchRef = useRef(false); // Debounced version of fetchItems for filter changes const fetchDebounceRef = useRef(null); const debouncedFetchItems = useCallback((entityFilter: EntityFilter, statusFilter: StatusFilter, silent: boolean, tab: QueueTab = 'mainQueue') => { if (fetchDebounceRef.current) { clearTimeout(fetchDebounceRef.current); } fetchDebounceRef.current = setTimeout(() => { fetchItems(entityFilter, statusFilter, silent, tab); }, 1000); // 1 second debounce }, [fetchItems]); // Clean up debounce on unmount useEffect(() => { return () => { if (fetchDebounceRef.current) { clearTimeout(fetchDebounceRef.current); } }; }, []); // Initial fetch on mount and filter changes useEffect(() => { if (!user) return; // Phase 1: Initial fetch (run once) if (!hasInitialFetchRef.current) { hasInitialFetchRef.current = true; isMountingRef.current = true; fetchItems(debouncedEntityFilter, debouncedStatusFilter, false) .then(() => { initialFetchCompleteRef.current = true; // Wait for DOM to paint before allowing subsequent fetches requestAnimationFrame(() => { isMountingRef.current = false; }); }); return; // Exit early, don't respond to filter changes yet } // Phase 2: Filter changes (only after initial fetch completes) if (!isMountingRef.current && initialFetchCompleteRef.current) { debouncedFetchItems(debouncedEntityFilter, debouncedStatusFilter, true, activeTab); } }, [debouncedEntityFilter, debouncedStatusFilter, user, activeTab, fetchItems, debouncedFetchItems]); // Polling for auto-refresh (only if realtime is disabled) useEffect(() => { // STRICT CHECK: Only enable polling if explicitly disabled if (!user || refreshMode !== 'auto' || loadingState === 'initial' || useRealtimeQueue) { if (useRealtimeQueue && refreshMode === 'auto') { console.log('βœ… Polling DISABLED - using realtime subscriptions'); } return; } console.log('⚠️ Polling ENABLED - interval:', pollInterval); const interval = setInterval(() => { console.log('πŸ”„ Polling refresh triggered'); fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true); }, pollInterval); return () => { clearInterval(interval); console.log('πŸ›‘ Polling stopped'); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, refreshMode, pollInterval, loadingState, useRealtimeQueue]); // Real-time subscription for NEW submissions (replaces polling) useEffect(() => { if (!user || !useRealtimeQueue) return; const channel = supabase .channel('moderation-new-submissions') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'content_submissions', }, async (payload) => { const newSubmission = payload.new as any; // Ignore if recently removed (optimistic update) if (recentlyRemovedRef.current.has(newSubmission.id)) { return; } // Only process pending/partially_approved submissions if (!['pending', 'partially_approved'].includes(newSubmission.status)) { return; } // Apply entity filter const matchesEntityFilter = filtersRef.current.entityFilter === 'all' || (filtersRef.current.entityFilter === 'photos' && newSubmission.submission_type === 'photo') || (filtersRef.current.entityFilter === 'submissions' && newSubmission.submission_type !== 'photo'); // Apply status filter const matchesStatusFilter = filtersRef.current.statusFilter === 'all' || (filtersRef.current.statusFilter === 'pending' && ['pending', 'partially_approved'].includes(newSubmission.status)) || filtersRef.current.statusFilter === newSubmission.status; if (matchesEntityFilter && matchesStatusFilter) { console.log('πŸ†• NEW submission detected:', newSubmission.id); // Fetch full submission details try { const { data: submission, error } = await supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, submission_items ( id, item_type, item_data, status ) `) .eq('id', newSubmission.id) .single(); if (error || !submission) { console.error('Error fetching submission details:', error); return; } // Fetch user profile const { data: profile } = await supabase .from('profiles') .select('user_id, username, display_name, avatar_url') .eq('user_id', submission.user_id) .maybeSingle(); // Resolve entity name const content = submission.content as any; let entityName = content?.name || 'Unknown'; let parkName: string | undefined; if (submission.submission_type === 'ride' && content?.entity_id) { const { data: ride } = await supabase .from('rides') .select('name, park_id') .eq('id', content.entity_id) .maybeSingle(); if (ride) { entityName = ride.name; if (ride.park_id) { const { data: park } = await supabase .from('parks') .select('name') .eq('id', ride.park_id) .maybeSingle(); if (park) parkName = park.name; } } } else if (submission.submission_type === 'park' && content?.entity_id) { const { data: park } = await supabase .from('parks') .select('name') .eq('id', content.entity_id) .maybeSingle(); if (park) entityName = park.name; } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) { const { data: company } = await supabase .from('companies') .select('name') .eq('id', content.entity_id) .maybeSingle(); if (company) entityName = company.name; } const fullItem: ModerationItem = { id: submission.id, type: 'content_submission', content: submission.content, created_at: submission.created_at, user_id: submission.user_id, status: submission.status, submission_type: submission.submission_type, user_profile: profile || undefined, entity_name: entityName, park_name: parkName, reviewed_at: submission.reviewed_at || undefined, reviewer_notes: submission.reviewer_notes || undefined, escalated: submission.escalated, assigned_to: submission.assigned_to || undefined, locked_until: submission.locked_until || undefined, }; // Add to pending items setPendingNewItems(prev => { if (prev.some(p => p.id === fullItem.id)) return prev; return [...prev, fullItem]; }); setNewItemsCount(prev => prev + 1); // Toast notification toast({ title: 'πŸ†• New Submission', description: `${fullItem.submission_type} - ${fullItem.entity_name}`, }); } catch (error) { console.error('Error processing new submission:', error); } } } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [user, useRealtimeQueue, toast]); // Helper function to debounce realtime updates const debouncedRealtimeUpdate = useCallback((submissionId: string, updateFn: () => void) => { const existingTimeout = realtimeUpdateDebounceRef.current.get(submissionId); if (existingTimeout) { clearTimeout(existingTimeout); } const newTimeout = setTimeout(() => { updateFn(); realtimeUpdateDebounceRef.current.delete(submissionId); }, 1000); // Wait 1000ms after last event realtimeUpdateDebounceRef.current.set(submissionId, newTimeout); }, []); // Cleanup debounce timeouts on unmount useEffect(() => { return () => { realtimeUpdateDebounceRef.current.forEach(timeout => clearTimeout(timeout)); realtimeUpdateDebounceRef.current.clear(); }; }, []); // Real-time subscription for UPDATED submissions useEffect(() => { if (!user || !useRealtimeQueue) return; const channel = supabase .channel('moderation-updated-submissions') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'content_submissions', }, async (payload) => { const updatedSubmission = payload.new as any; // Ignore if recently removed (optimistic update in progress) if (recentlyRemovedRef.current.has(updatedSubmission.id)) { console.log('⏭️ Ignoring UPDATE for recently removed submission:', updatedSubmission.id); return; } debouncedRealtimeUpdate(updatedSubmission.id, async () => { // Check if submission matches current filters const matchesEntityFilter = filtersRef.current.entityFilter === 'all' || (filtersRef.current.entityFilter === 'photos' && updatedSubmission.submission_type === 'photo') || (filtersRef.current.entityFilter === 'submissions' && updatedSubmission.submission_type !== 'photo'); const matchesStatusFilter = filtersRef.current.statusFilter === 'all' || (filtersRef.current.statusFilter === 'pending' && ['pending', 'partially_approved'].includes(updatedSubmission.status)) || filtersRef.current.statusFilter === updatedSubmission.status; const wasInQueue = itemsRef.current.some(i => i.id === updatedSubmission.id); const shouldBeInQueue = matchesEntityFilter && matchesStatusFilter; if (wasInQueue && !shouldBeInQueue) { // Submission moved out of current filter (e.g., pending β†’ approved) console.log('❌ Submission moved out of queue:', updatedSubmission.id); setItems(prev => { const exists = prev.some(i => i.id === updatedSubmission.id); if (!exists) { console.log('βœ… Realtime: Item already removed', updatedSubmission.id); return prev; // Keep existing array reference } console.log('❌ Realtime: Removing item from queue', updatedSubmission.id); return prev.filter(i => i.id !== updatedSubmission.id); }); } else if (shouldBeInQueue) { // Submission should be in queue - update it console.log('πŸ”„ Submission updated in queue:', updatedSubmission.id); // Fetch full details try { const { data: submission, error } = await supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, submission_items ( id, item_type, item_data, status ) `) .eq('id', updatedSubmission.id) .single(); if (error || !submission) return; // Get user profile const { data: profile } = await supabase .from('profiles') .select('user_id, username, display_name, avatar_url') .eq('user_id', submission.user_id) .maybeSingle(); // Resolve entity name (simplified - reuse existing logic) const content = submission.content as any; const entityName = content?.name || 'Unknown'; const fullItem: ModerationItem = { id: submission.id, type: 'content_submission', content: submission.content, created_at: submission.created_at, user_id: submission.user_id, status: submission.status, submission_type: submission.submission_type, user_profile: profile || undefined, entity_name: entityName, reviewed_at: submission.reviewed_at || undefined, reviewer_notes: submission.reviewer_notes || undefined, escalated: submission.escalated, assigned_to: submission.assigned_to || undefined, locked_until: submission.locked_until || undefined, submission_items: submission.submission_items || undefined, }; // Update or add to queue setItems(prev => { const exists = prev.some(i => i.id === fullItem.id); if (exists) { // Check if item actually changed before updating const currentItem = prev.find(i => i.id === fullItem.id); if (!currentItem) return prev; // Deep comparison of critical fields const hasChanged = currentItem.status !== fullItem.status || currentItem.reviewed_at !== fullItem.reviewed_at || currentItem.reviewer_notes !== fullItem.reviewer_notes || currentItem.assigned_to !== fullItem.assigned_to || currentItem.locked_until !== fullItem.locked_until || currentItem.escalated !== fullItem.escalated; // Only check content if critical fields match (performance optimization) let contentChanged = false; if (!hasChanged && currentItem.content && fullItem.content) { // Compare content reference first if (currentItem.content !== fullItem.content) { // Check each key for actual value changes (one level deep) const currentKeys = Object.keys(currentItem.content).sort(); const fullKeys = Object.keys(fullItem.content).sort(); if (currentKeys.length !== fullKeys.length || !currentKeys.every((key, i) => key === fullKeys[i])) { contentChanged = true; } else { for (const key of currentKeys) { if (currentItem.content[key] !== fullItem.content[key]) { contentChanged = true; break; } } } } } if (!hasChanged && !contentChanged) { console.log('βœ… Realtime UPDATE: No changes detected for', fullItem.id); return prev; // Keep existing array reference - PREVENTS RE-RENDER } console.log('πŸ”„ Realtime UPDATE: Changes detected for', fullItem.id); // Update ONLY changed fields to preserve object stability return prev.map(i => { if (i.id !== fullItem.id) return i; // Create minimal update object with only changed fields const updates: Partial = {}; if (i.status !== fullItem.status) updates.status = fullItem.status; if (i.reviewed_at !== fullItem.reviewed_at) updates.reviewed_at = fullItem.reviewed_at; if (i.reviewer_notes !== fullItem.reviewer_notes) updates.reviewer_notes = fullItem.reviewer_notes; if (i.assigned_to !== fullItem.assigned_to) updates.assigned_to = fullItem.assigned_to; if (i.locked_until !== fullItem.locked_until) updates.locked_until = fullItem.locked_until; if (i.escalated !== fullItem.escalated) updates.escalated = fullItem.escalated; if (contentChanged) updates.content = fullItem.content; if (fullItem.submission_items) updates.submission_items = fullItem.submission_items; // Only create new object if there are actual updates return Object.keys(updates).length > 0 ? { ...i, ...updates } : i; }); } else { console.log('πŸ†• Realtime UPDATE: Adding new item', fullItem.id); return [fullItem, ...prev]; } }); } catch (error) { console.error('Error processing updated submission:', error); } } }); } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [user, useRealtimeQueue, debouncedRealtimeUpdate]); const handleResetToPending = async (item: ModerationItem) => { setActionLoading(item.id); try { const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService'); await resetRejectedItemsToPending(item.id); toast({ title: "Reset Complete", description: "Submission and all items have been reset to pending status", }); // Optimistic update - item will reappear via realtime setItems(prev => prev.filter(i => i.id !== item.id)); } catch (error: any) { console.error('Error resetting submission:', error); toast({ title: "Reset Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }; const handleRetryFailedItems = async (item: ModerationItem) => { setActionLoading(item.id); // Optimistic UI update - remove from queue immediately const shouldRemove = ( activeStatusFilter === 'pending' || activeStatusFilter === 'flagged' || activeStatusFilter === 'partially_approved' ); if (shouldRemove) { requestAnimationFrame(() => { setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000); // Increased from 3000 }); } try { // Fetch failed/rejected submission items const { data: failedItems, error: fetchError } = await supabase .from('submission_items') .select('id') .eq('submission_id', item.id) .eq('status', 'rejected'); if (fetchError) throw fetchError; if (!failedItems || failedItems.length === 0) { toast({ title: "No Failed Items", description: "All items have been processed successfully", }); return; } // Call edge function to retry failed items const { data, error } = await supabase.functions.invoke( 'process-selective-approval', { body: { itemIds: failedItems.map(i => i.id), submissionId: item.id } } ); if (error) throw error; toast({ title: "Retry Complete", description: `Processed ${failedItems.length} failed item(s)`, }); } catch (error: any) { console.error('Error retrying failed items:', error); toast({ title: "Retry Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }; const handleModerationAction = async ( item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string ) => { // Prevent multiple clicks on the same item if (actionLoading === item.id) { return; } setActionLoading(item.id); // Determine if item should be removed from current view after action const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') && (action === 'approved' || action === 'rejected'); // Optimistic UI update with smooth exit animation if (shouldRemove) { // Step 1: Mark item as "removing" for exit animation setItems(prev => prev.map(i => i.id === item.id ? { ...i, _removing: true } : i )); // Step 2: Wait for exit animation (300ms), then remove setTimeout(() => { setItems(prev => prev.filter(i => i.id !== item.id)); // Mark as recently removed - ignore realtime updates for 10 seconds recentlyRemovedRef.current.add(item.id); setTimeout(() => { recentlyRemovedRef.current.delete(item.id); }, 10000); }, 300); } else { setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: action } : i )); } // Release lock if this submission is claimed by current user if (queue.currentLock?.submissionId === item.id) { await queue.releaseLock(item.id); } try { // Handle composite ride submissions with sequential entity creation if (action === 'approved' && item.type === 'content_submission' && (item.submission_type === 'ride_with_manufacturer' || item.submission_type === 'ride_with_model' || item.submission_type === 'ride_with_manufacturer_and_model')) { let manufacturerId = item.content.ride?.manufacturer_id; let rideModelId = item.content.ride?.ride_model_id; // STEP 1: Create manufacturer if needed if (item.content.new_manufacturer) { const { data: newManufacturer, error: mfrError } = await supabase .from('companies') .insert({ name: item.content.new_manufacturer.name, slug: item.content.new_manufacturer.slug, description: item.content.new_manufacturer.description, company_type: 'manufacturer', person_type: item.content.new_manufacturer.person_type || 'company', website_url: item.content.new_manufacturer.website_url, founded_year: item.content.new_manufacturer.founded_year, headquarters_location: item.content.new_manufacturer.headquarters_location }) .select() .single(); if (mfrError) { throw new Error(`Failed to create manufacturer: ${mfrError.message}`); } manufacturerId = newManufacturer.id; toast({ title: "Manufacturer Created", description: `Created ${newManufacturer.name}`, }); } // STEP 2: Create ride model if needed if (item.content.new_ride_model) { const modelManufacturerId = manufacturerId || item.content.new_ride_model.manufacturer_id; if (!modelManufacturerId) { throw new Error('Cannot create ride model: No manufacturer ID available'); } const { data: newModel, error: modelError } = await supabase .from('ride_models') .insert({ name: item.content.new_ride_model.name, slug: item.content.new_ride_model.slug, manufacturer_id: modelManufacturerId, category: item.content.new_ride_model.category, ride_type: item.content.new_ride_model.ride_type, description: item.content.new_ride_model.description }) .select() .single(); if (modelError) { throw new Error(`Failed to create ride model: ${modelError.message}`); } rideModelId = newModel.id; toast({ title: "Ride Model Created", description: `Created ${newModel.name}`, }); } // STEP 3: Create the ride const { error: rideError } = await supabase .from('rides') .insert({ ...item.content.ride, manufacturer_id: manufacturerId, ride_model_id: rideModelId, park_id: item.content.park_id }); if (rideError) { throw new Error(`Failed to create ride: ${rideError.message}`); } // STEP 4: Update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); if (updateError) throw updateError; toast({ title: "Submission Approved", description: "All entities created successfully", }); // Optimistic update - remove from queue setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000); // Increased timeout return; } // Handle photo submissions - create photos records when approved if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') { try { // Fetch photo submission from new relational tables const { data: photoSubmission, error: fetchError } = await supabase .from('photo_submissions') .select(` *, items:photo_submission_items(*), submission:content_submissions!inner(user_id, status) `) .eq('submission_id', item.id) .single(); if (fetchError || !photoSubmission) { console.error('Failed to fetch photo submission:', fetchError); throw new Error('Failed to fetch photo submission data'); } if (!photoSubmission.items || photoSubmission.items.length === 0) { console.error('No photo items found in submission'); throw new Error('No photos found in submission'); } // Check if photos already exist for this submission (in case of re-approval) const { data: existingPhotos } = await supabase .from('photos') .select('id') .eq('submission_id', item.id); if (existingPhotos && existingPhotos.length > 0) { // Just update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); } else { // Create new photo records from photo_submission_items const photoRecords = photoSubmission.items.map((item) => ({ entity_id: photoSubmission.entity_id, entity_type: photoSubmission.entity_type, cloudflare_image_id: item.cloudflare_image_id, cloudflare_image_url: item.cloudflare_image_url, title: item.title || null, caption: item.caption || null, date_taken: item.date_taken || null, order_index: item.order_index, submission_id: photoSubmission.submission_id, submitted_by: photoSubmission.submission?.user_id, approved_by: user?.id, approved_at: new Date().toISOString(), })); const { data: createdPhotos, error: insertError } = await supabase .from('photos') .insert(photoRecords) .select(); if (insertError) { console.error('Failed to insert photos:', insertError); throw insertError; } } // Update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); if (updateError) { console.error('Error updating submission:', updateError); throw updateError; } toast({ title: "Photos Approved", description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, }); // Optimistic update - remove from queue setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); return; } catch (error: any) { console.error('Photo approval error:', error); throw error; } } // Check if this submission has submission_items that need processing if (item.type === 'content_submission') { const { data: submissionItems, error: itemsError } = await supabase .from('submission_items') .select('id, status') .eq('submission_id', item.id) .in('status', ['pending', 'rejected']); if (!itemsError && submissionItems && submissionItems.length > 0) { if (action === 'approved') { // Call the edge function to process all items const { data: approvalData, error: approvalError } = await supabase.functions.invoke( 'process-selective-approval', { body: { itemIds: submissionItems.map(i => i.id), submissionId: item.id } } ); if (approvalError) { throw new Error(`Failed to process submission items: ${approvalError.message}`); } toast({ title: "Submission Approved", description: `Successfully processed ${submissionItems.length} item(s)`, }); // Optimistic update - remove from queue setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); return; } else if (action === 'rejected') { // Cascade rejection to all pending items const { error: rejectError } = await supabase .from('submission_items') .update({ status: 'rejected', rejection_reason: moderatorNotes || 'Parent submission rejected', updated_at: new Date().toISOString() }) .eq('submission_id', item.id) .eq('status', 'pending'); if (rejectError) { console.error('Failed to cascade rejection:', rejectError); // Don't fail the whole operation, just log it } } } } // Standard moderation flow for other items 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 if (user) { updateData[reviewerField] = user.id; } if (moderatorNotes) { updateData.reviewer_notes = moderatorNotes; } const { error, data } = await supabase .from(table) .update(updateData) .eq('id', item.id) .select(); if (error) { console.error('Database update error:', error); throw error; } // 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.'); } toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, }); // Clear notes after successful update setNotes(prev => { const newNotes = { ...prev }; delete newNotes[item.id]; return newNotes; }); // Optimistic update - remove from queue if approved or rejected if (action === 'approved' || action === 'rejected') { setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); } } catch (error: any) { console.error('Error moderating content:', error); // Revert optimistic update - restore item to list setItems(prev => { const exists = prev.find(i => i.id === item.id); if (exists) { return prev.map(i => i.id === item.id ? item : i); } else { return [...prev, item]; } }); 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) { return; } setActionLoading(item.id); // Remove item from UI immediately to prevent flickering setItems(prev => prev.filter(i => i.id !== item.id)); try { // 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)) { for (const photo of photosArray) { let imageId = ''; // First try to use the stored imageId directly if (photo.imageId) { imageId = photo.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]; } } } 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'); } } } // Step 2: Delete photos from Cloudflare Images (if any valid IDs) if (validImageIds.length > 0) { const deletePromises = validImageIds.map(async (imageId) => { try { // Use Supabase SDK - automatically includes session token const { data, error } = await supabase.functions.invoke('upload-image', { method: 'DELETE', body: { imageId } }); if (error) { throw new Error(`Failed to delete image: ${error.message}`); } } 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 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'); } 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 'partially_approved': 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 'partially_approved': return `No partially approved ${entityLabel} found.`; 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.`; } }; // Sort items function const sortItems = useCallback((items: ModerationItem[], config: SortConfig): ModerationItem[] => { const sorted = [...items]; sorted.sort((a, b) => { let compareA: any; let compareB: any; switch (config.field) { case 'created_at': compareA = new Date(a.created_at).getTime(); compareB = new Date(b.created_at).getTime(); break; case 'username': compareA = (a.user_profile?.username || '').toLowerCase(); compareB = (b.user_profile?.username || '').toLowerCase(); break; case 'submission_type': compareA = a.submission_type || ''; compareB = b.submission_type || ''; break; case 'status': compareA = a.status; compareB = b.status; break; case 'escalated': compareA = a.escalated ? 1 : 0; compareB = b.escalated ? 1 : 0; break; default: return 0; } let result = 0; if (typeof compareA === 'string' && typeof compareB === 'string') { result = compareA.localeCompare(compareB); } else if (typeof compareA === 'number' && typeof compareB === 'number') { result = compareA - compareB; } return config.direction === 'asc' ? result : -result; }); return sorted; }, []); // 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 = () => { // Show skeleton during ANY loading state (except refreshing) if (loadingState === 'initial' || loadingState === 'loading') { return (
); } if (items.length === 0) { return (

No items found

{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}

); } // Apply client-side sorting const sortedItems = useMemo(() => { return sortItems(items, sortConfig); }, [items, sortConfig]); return (
{sortedItems.map((item, index) => (
queue.claimSubmission(id)} onDeleteSubmission={handleDeleteSubmission} onInteractionFocus={handleInteractionFocus} onInteractionBlur={handleInteractionBlur} />
))}
); }; const clearFilters = () => { setActiveEntityFilter('all'); setActiveStatusFilter('pending'); setSortConfig({ field: 'created_at', direction: 'asc' }); }; const getEntityFilterIcon = (filter: EntityFilter) => { switch (filter) { case 'reviews': return ; case 'submissions': return ; case 'photos': return ; default: return ; } }; // Helper to format lock timer const formatLockTimer = (ms: number): string => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; // Handle claim next action const handleClaimNext = async () => { await queue.claimNext(); // No refresh needed - realtime subscription handles updates }; return (
{/* Queue Statistics & Claim Button */} {queue.queueStats && (
{/* Stats Grid */}
{queue.queueStats.pendingCount}
Pending
{queue.queueStats.assignedToMe}
Assigned to Me
{queue.queueStats.avgWaitHours.toFixed(1)}h
Avg Wait
{/* Claim/Lock Status */}
{queue.currentLock ? ( <> {/* Lock Timer */}
Lock: {formatLockTimer(queue.getTimeRemaining() || 0)}
{/* Extend Lock Button (show when < 5 min left) */} {(queue.getTimeRemaining() || 0) < 5 * 60 * 1000 && ( )} ) : ( )}
)} {/* Filter Bar */}

Moderation Queue

{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
)}
{/* Active Filters Display */} {(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
Active filters: {activeEntityFilter !== 'all' && ( {getEntityFilterIcon(activeEntityFilter)} {activeEntityFilter} )} {activeStatusFilter !== 'pending' && ( {activeStatusFilter} )} {sortConfig.field !== 'created_at' && ( {sortConfig.direction === 'asc' ? : } Sort: {sortConfig.field === 'username' ? 'Submitter' : sortConfig.field === 'submission_type' ? 'Type' : sortConfig.field === 'escalated' ? 'Escalated' : sortConfig.field === 'status' ? 'Status' : 'Date'} )}
)} {/* Auto-refresh Status Indicator */} {refreshMode === 'auto' && (
Auto-refresh active
β€’ Checking every {Math.round(pollInterval / 1000)}s
)} {/* New Items Notification - Enhanced */} {newItemsCount > 0 && (
New Items Available {newItemsCount} new {newItemsCount === 1 ? 'submission' : 'submissions'} pending review
)} {/* Queue Content */} {/* Pagination Controls */} {totalPages > 1 && loadingState === 'ready' && (
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} items {!isMobile && ( <> β€’ )}
{isMobile ? (
Page {currentPage} of {totalPages}
) : ( { setLoadingState('loading'); setCurrentPage(p => Math.max(1, p - 1)); window.scrollTo({ top: 0, behavior: 'smooth' }); }} className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} /> {currentPage > 3 && ( <> { setLoadingState('loading'); setCurrentPage(1); window.scrollTo({ top: 0, behavior: 'smooth' }); }} isActive={currentPage === 1} > 1 {currentPage > 4 && } )} {Array.from({ length: totalPages }, (_, i) => i + 1) .filter(page => page >= currentPage - 2 && page <= currentPage + 2) .map(page => ( { setLoadingState('loading'); setCurrentPage(page); window.scrollTo({ top: 0, behavior: 'smooth' }); }} isActive={currentPage === page} > {page} )) } {currentPage < totalPages - 2 && ( <> {currentPage < totalPages - 3 && } { setLoadingState('loading'); setCurrentPage(totalPages); window.scrollTo({ top: 0, behavior: 'smooth' }); }} isActive={currentPage === totalPages} > {totalPages} )} { setLoadingState('loading'); setCurrentPage(p => Math.min(totalPages, p + 1)); window.scrollTo({ top: 0, behavior: 'smooth' }); }} className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} /> )}
)} {/* Photo Modal */} setPhotoModalOpen(false)} /> {/* Submission Review Manager for multi-entity submissions */} {selectedSubmissionId && ( { // No refresh needed - item was removed optimistically setReviewManagerOpen(false); }} /> )}
); }); ModerationQueue.displayName = 'ModerationQueue';