diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index ad7bd76b..d2e41f00 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,20 +1,13 @@ -import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef, useMemo } from 'react'; -import { CheckCircle, XCircle, AlertTriangle, UserCog, Zap } from 'lucide-react'; +import { useState, useImperativeHandle, forwardRef } from 'react'; import { Card, CardContent } from '@/components/ui/card'; -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 { EscalationDialog } from './EscalationDialog'; -import { ReassignDialog } from './ReassignDialog'; -import { smartMergeArray } from '@/lib/smartStateUpdate'; -import { useDebounce } from '@/hooks/useDebounce'; +import { useModerationQueueManager } from '@/hooks/moderation'; import { QueueItem } from './QueueItem'; import { QueueSkeleton } from './QueueSkeleton'; import { LockStatusDisplay } from './LockStatusDisplay'; @@ -26,2062 +19,177 @@ import { AutoRefreshIndicator } from './AutoRefreshIndicator'; import { NewItemsAlert } from './NewItemsAlert'; import { EmptyQueueState } from './EmptyQueueState'; import { QueuePagination } from './QueuePagination'; -import type { - ModerationItem, - EntityFilter, - StatusFilter, - QueueTab, - SortField, - SortDirection, - SortConfig, - LoadingState, - ModerationQueueRef, -} from '@/types/moderation'; +import type { ModerationQueueRef } from '@/types/moderation'; export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); - const [items, setItems] = useState([]); - const [loadingState, setLoadingState] = useState('initial'); - const [actionLoading, setActionLoading] = useState(null); + const { user } = useAuth(); + const { toast } = useToast(); + const { isAdmin, isSuperuser } = useUserRole(); + const adminSettings = useAdminSettings(); + + // Initialize queue manager (replaces all state management, fetchItems, effects) + const queueManager = useModerationQueueManager({ + user, + isAdmin: isAdmin(), + isSuperuser: isSuperuser(), + toast, + settings: { + refreshMode: adminSettings.getAdminPanelRefreshMode(), + pollInterval: adminSettings.getAdminPanelPollInterval(), + refreshStrategy: adminSettings.getAutoRefreshStrategy(), + preserveInteraction: adminSettings.getPreserveInteractionState(), + useRealtimeQueue: adminSettings.getUseRealtimeQueue(), + refreshOnTabVisible: adminSettings.getRefreshOnTabVisible(), + } + }); + + // UI-only state 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 [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; - const pauseFetchingRef = useRef(false); - // 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, - getRefreshOnTabVisible - } = useAdminSettings(); - const refreshMode = getAdminPanelRefreshMode(); - const pollInterval = getAdminPanelPollInterval(); - const refreshStrategy = getAutoRefreshStrategy(); - const preserveInteraction = getPreserveInteractionState(); - const useRealtimeQueue = getUseRealtimeQueue(); - const refreshOnTabVisible = getRefreshOnTabVisible(); - - // 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 refreshOnTabVisibleRef = useRef(refreshOnTabVisible); - const activeTabRef = useRef(activeTab); - const userRef = useRef(user); - const toastRef = useRef(toast); - const isAdminRef = useRef(isAdmin); - const isSuperuserRef = useRef(isSuperuser); - - useEffect(() => { - refreshStrategyRef.current = refreshStrategy; - preserveInteractionRef.current = preserveInteraction; - refreshOnTabVisibleRef.current = refreshOnTabVisible; - userRef.current = user; - toastRef.current = toast; - isAdminRef.current = isAdmin; - isSuperuserRef.current = isSuperuser; - }, [refreshStrategy, preserveInteraction, refreshOnTabVisible, user, toast, isAdmin, isSuperuser]); - - // Sync activeTab with ref - useEffect(() => { - activeTabRef.current = activeTab; - }, [activeTab]); - - // 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; - } - - // Check if tab is hidden - pause all fetching - if (pauseFetchingRef.current || document.hidden) { - console.log('⏸️ Fetch paused (tab hidden)'); - 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 (!userRef.current) 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, activeTabRef.current); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedEntityFilter, debouncedStatusFilter]); - // Note: user object reference changes shouldn't trigger filter refetch - using stable userRef instead - - // 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; - - // Queue updates if tab is hidden - if (document.hidden) { - console.log('📴 Realtime event received while hidden - queuing for later'); - return; - } - - // 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; - - // Queue updates if tab is hidden - if (document.hidden) { - console.log('📴 Realtime UPDATE received while hidden - queuing for later'); - return; - } - - // 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]); - - // Visibility change handler - smart tab switching behavior - useEffect(() => { - const handleVisibilityChange = () => { - if (document.hidden) { - console.log('📴 Tab hidden - pausing queue updates'); - pauseFetchingRef.current = true; - } else { - console.log('📱 Tab visible - resuming queue updates'); - pauseFetchingRef.current = false; - - // CRITICAL: Check admin setting for auto-refresh behavior - const shouldRefresh = refreshOnTabVisibleRef.current; - - console.log('🔍 Tab visible check - shouldRefresh setting:', shouldRefresh, - 'initialFetchComplete:', initialFetchCompleteRef.current, - 'isMounting:', isMountingRef.current); - - // Only refresh if admin setting explicitly enables it - if (shouldRefresh === true && initialFetchCompleteRef.current && !isMountingRef.current) { - console.log('🔄 Tab became visible - triggering refresh (admin setting ENABLED)'); - fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true, activeTabRef.current); - } else { - console.log('✅ Tab became visible - NO refresh (admin setting disabled or conditions not met)'); - // Realtime subscriptions will handle updates naturally - } - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, []); // Empty deps - all values come from refs - - 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'; - } - }; - - - // 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) => { + // UI-specific handlers + const handleNoteChange = (id: string, value: string) => { setNotes(prev => ({ ...prev, [id]: value })); - }, []); - - const handleOpenPhotos = useCallback((photos: any[], index: number) => { + }; + + const handleOpenPhotos = (photos: any[], index: number) => { setSelectedPhotos(photos); setSelectedPhotoIndex(index); setPhotoModalOpen(true); - }, []); - - const handleOpenReviewManager = useCallback((id: string) => { - setSelectedSubmissionId(id); + }; + + const handleOpenReviewManager = (submissionId: string) => { + setSelectedSubmissionId(submissionId); 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 ( -
- -
- ); + }; + + // Expose imperative API + useImperativeHandle(ref, () => ({ + refresh: async () => { + await queueManager.refresh(); } - - if (items.length === 0) { - return ( - - ); - } - - // 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 handleEntityFilterChange = (filter: EntityFilter) => { - setActiveEntityFilter(filter); - setLoadingState('loading'); - }; - - const handleStatusFilterChange = (filter: StatusFilter) => { - setActiveStatusFilter(filter); - setLoadingState('loading'); - }; - - const handleSortConfigChange = (config: SortConfig) => { - setSortConfig(config); - }; - - const handleShowNewItems = () => { - if (pendingNewItems.length > 0) { - setItems(prev => [...pendingNewItems, ...prev]); - setPendingNewItems([]); - setNewItemsCount(0); - console.log('✅ New items merged into queue:', pendingNewItems.length); - } - }; - - const hasActiveFilters = - activeEntityFilter !== 'all' || - activeStatusFilter !== 'pending' || - sortConfig.field !== 'created_at'; - - // Handle claim next action - const handleClaimNext = async () => { - await queue.claimNext(); - // No refresh needed - realtime subscription handles updates - }; - + })); + return (
- {/* Queue Statistics & Claim Button */} - {queue.queueStats && ( + {/* Queue Statistics & Lock Status */} + {queueManager.queue.queueStats && (
- + { await queueManager.queue.claimNext(); }} + onExtendLock={queueManager.queue.extendLock} + onReleaseLock={queueManager.queue.releaseLock} + getTimeRemaining={queueManager.queue.getTimeRemaining} + getLockProgress={queueManager.queue.getLockProgress} />
)} - + {/* Filter Bar */} - + {/* Active Filters Display */} - - - {/* Auto-refresh Status Indicator */} - - - {/* New Items Notification */} - - - {/* Queue Content */} - - - {/* Pagination Controls */} - {loadingState === 'ready' && ( - { - setLoadingState('loading'); - setCurrentPage(page); - }} - onPageSizeChange={(size) => { - setLoadingState('loading'); - setPageSize(size); - setCurrentPage(1); - }} + {queueManager.filters.hasActiveFilters && ( + )} - {/* Photo Modal */} + {/* Auto-refresh Indicator */} + {adminSettings.getAdminPanelRefreshMode() === 'auto' && ( + + )} + + {/* New Items Alert */} + {queueManager.newItemsCount > 0 && ( + + )} + + {/* Queue Content */} + {queueManager.loadingState === 'loading' || queueManager.loadingState === 'initial' ? ( + + ) : queueManager.items.length === 0 ? ( + + ) : ( +
+ {queueManager.items.map((item, index) => ( + queueManager.markInteracting(id, true)} + onInteractionBlur={(id) => queueManager.markInteracting(id, false)} + /> + ))} +
+ )} + + {/* Pagination */} + {queueManager.loadingState === 'ready' && queueManager.pagination.totalPages > 1 && ( + + )} + + {/* Modals */} ((props, ref) => { onClose={() => setPhotoModalOpen(false)} /> - {/* Submission Review Manager for multi-entity submissions */} {selectedSubmissionId && ( { - // No refresh needed - item was removed optimistically - setReviewManagerOpen(false); - }} + onComplete={() => setReviewManagerOpen(false)} /> )}
@@ -2107,5 +211,4 @@ export const ModerationQueue = forwardRef((props, ref) => { ModerationQueue.displayName = 'ModerationQueue'; -// Re-export types for backwards compatibility export type { ModerationQueueRef } from '@/types/moderation'; diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index 8147e8f0..4e631e17 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -868,7 +868,7 @@ export function useModerationQueueManager( showNewItems, interactingWith, markInteracting, - refresh: () => fetchItems(false), + refresh: async () => { await fetchItems(false); }, performAction, deleteSubmission, resetToPending,