import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { AlertCircle, Info } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { TooltipProvider } from '@/components/ui/tooltip'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { getErrorMessage } from '@/lib/errorHandler'; import { supabase } from '@/lib/supabaseClient'; import * as localStorage from '@/lib/localStorage'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { ItemEditDialog } from './ItemEditDialog'; import { ItemSelectorDialog } from './ItemSelectorDialog'; import { useIsMobile } from '@/hooks/use-mobile'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueueManager } from '@/hooks/moderation'; import { QueueItem } from './QueueItem'; import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary'; import { QueueSkeleton } from './QueueSkeleton'; import { EnhancedLockStatusDisplay } from './EnhancedLockStatusDisplay'; import { getLockStatus } from '@/lib/moderation/lockHelpers'; import { QueueStats } from './QueueStats'; import { QueueFilters } from './QueueFilters'; import { ActiveFiltersDisplay } from './ActiveFiltersDisplay'; import { AutoRefreshIndicator } from './AutoRefreshIndicator'; import { NewItemsAlert } from './NewItemsAlert'; import { EnhancedEmptyState } from './EnhancedEmptyState'; import { QueuePagination } from './QueuePagination'; import { ConfirmationDialog } from './ConfirmationDialog'; import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp'; import { SuperuserQueueControls } from './SuperuserQueueControls'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { fetchSubmissionItems, type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import type { ModerationQueueRef, ModerationItem } from '@/types/moderation'; import type { PhotoItem } from '@/types/photos'; interface ModerationQueueProps { optimisticallyUpdateStats?: (delta: Partial<{ pendingSubmissions: number; openReports: number; flaggedContent: number }>) => void; } export const ModerationQueue = forwardRef((props, ref) => { const { optimisticallyUpdateStats } = props; const isMobile = useIsMobile(); const { user } = useAuth(); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const adminSettings = useAdminSettings(); // Extract settings values to stable primitives for memoization const refreshMode = adminSettings.getAdminPanelRefreshMode(); const pollInterval = adminSettings.getAdminPanelPollInterval(); const refreshStrategy = adminSettings.getAutoRefreshStrategy(); const preserveInteraction = adminSettings.getPreserveInteractionState(); const useRealtimeQueue = adminSettings.getUseRealtimeQueue(); // Memoize settings object using stable primitive dependencies const settings = useMemo(() => ({ refreshMode, pollInterval, refreshStrategy, preserveInteraction, useRealtimeQueue, }), [refreshMode, pollInterval, refreshStrategy, preserveInteraction, useRealtimeQueue]); // Initialize queue manager (replaces all state management, fetchItems, effects) const queueManager = useModerationQueueManager({ user, isAdmin: isAdmin(), isSuperuser: isSuperuser(), toast, optimisticallyUpdateStats, settings, }); // UI-only state const [notes, setNotes] = useState>({}); const [transactionStatuses, setTransactionStatuses] = useState>(() => { // Restore from localStorage on mount return localStorage.getJSON('moderation-queue-transaction-statuses', {}); }); 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 [showItemEditDialog, setShowItemEditDialog] = useState(false); const [editingItem, setEditingItem] = useState(null); const [showItemSelector, setShowItemSelector] = useState(false); const [availableItems, setAvailableItems] = useState([]); const [bulkEditMode, setBulkEditMode] = useState(false); const [bulkEditItems, setBulkEditItems] = useState([]); const [activeLocksCount, setActiveLocksCount] = useState(0); const [lockRestored, setLockRestored] = useState(false); const [initialLoadComplete, setInitialLoadComplete] = useState(false); // Confirmation dialog state const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; title: string; description: string; onConfirm: () => void; }>({ open: false, title: '', description: '', onConfirm: () => {}, }); // Keyboard shortcuts help dialog const [showShortcutsHelp, setShowShortcutsHelp] = useState(false); // Offline detection state const [isOffline, setIsOffline] = useState(!navigator.onLine); // Persist transaction statuses to localStorage useEffect(() => { localStorage.setJSON('moderation-queue-transaction-statuses', transactionStatuses); }, [transactionStatuses]); // Offline detection effect useEffect(() => { const handleOnline = () => { setIsOffline(false); toast({ title: 'Connection Restored', description: 'You are back online. Refreshing queue...', }); queueManager.refresh(); }; const handleOffline = () => { setIsOffline(true); }; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, [queueManager, toast]); // Auto-dismiss lock restored banner after 10 seconds useEffect(() => { if (lockRestored && queueManager.queue.currentLock) { const timer = setTimeout(() => { setLockRestored(false); }, 10000); // Auto-dismiss after 10 seconds return () => clearTimeout(timer); } }, [lockRestored, queueManager.queue.currentLock]); // Fetch active locks count for superusers const isSuperuserValue = isSuperuser(); useEffect(() => { if (!isSuperuserValue) return; const fetchActiveLocksCount = async () => { const { count } = await supabase .from('content_submissions') .select('id', { count: 'exact', head: true }) .not('assigned_to', 'is', null) .gt('locked_until', new Date().toISOString()); setActiveLocksCount(count || 0); }; fetchActiveLocksCount(); // Refresh count periodically const interval = setInterval(fetchActiveLocksCount, 30000); // Every 30s return () => clearInterval(interval); }, [isSuperuserValue]); // Track if lock was restored from database useEffect(() => { if (!initialLoadComplete) { setInitialLoadComplete(true); return; } if (queueManager.queue.currentLock && !lockRestored) { // If we have a lock after initial load but haven't claimed in this session setLockRestored(true); } }, [queueManager.queue.currentLock, lockRestored, initialLoadComplete]); // Virtual scrolling setup const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: queueManager.items.length, getScrollElement: () => parentRef.current, estimateSize: () => 420, // Estimated average height of QueueItem (card + spacing) overscan: 3, // Render 3 items above/below viewport for smoother scrolling enabled: queueManager.items.length > 10, // Only enable virtual scrolling for 10+ items }); // UI-specific handlers const handleNoteChange = (id: string, value: string) => { setNotes(prev => ({ ...prev, [id]: value })); }; // Transaction status helpers const setTransactionStatus = useCallback((submissionId: string, status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed', message?: string) => { setTransactionStatuses(prev => ({ ...prev, [submissionId]: { status, message } })); // Auto-clear completed/failed statuses after 5 seconds if (status === 'completed' || status === 'failed') { setTimeout(() => { setTransactionStatuses(prev => { const updated = { ...prev }; if (updated[submissionId]?.status === status) { updated[submissionId] = { status: 'idle' }; } return updated; }); }, 5000); } }, []); // Wrap performAction to track transaction status const handlePerformAction = useCallback(async (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => { setTransactionStatus(item.id, 'processing'); try { await queueManager.performAction(item, action, notes); setTransactionStatus(item.id, 'completed'); } catch (error: any) { // Check for timeout if (error?.type === 'timeout' || error?.message?.toLowerCase().includes('timeout')) { setTransactionStatus(item.id, 'timeout', error.message); } // Check for cached/409 else if (error?.status === 409 || error?.message?.toLowerCase().includes('duplicate')) { setTransactionStatus(item.id, 'cached', 'Using cached result from duplicate request'); } // Generic failure else { setTransactionStatus(item.id, 'failed', error.message); } throw error; // Re-throw to allow normal error handling } }, [queueManager, setTransactionStatus]); // Wrapped delete with confirmation const handleDeleteSubmission = useCallback((item: ModerationItem) => { setConfirmDialog({ open: true, title: 'Delete Submission', description: 'Are you sure you want to permanently delete this submission? This action cannot be undone.', onConfirm: () => queueManager.deleteSubmission(item), }); }, [queueManager]); // Superuser force release lock const handleSuperuserReleaseLock = useCallback(async (submissionId: string) => { await queueManager.queue.superuserReleaseLock(submissionId); // Refresh locks count and queue setActiveLocksCount(prev => Math.max(0, prev - 1)); queueManager.refresh(); }, [queueManager]); // Superuser clear all locks const handleClearAllLocks = useCallback(async () => { const count = await queueManager.queue.superuserReleaseAllLocks(); setActiveLocksCount(0); // Force queue refresh queueManager.refresh(); }, [queueManager]); // Clear filters handler const handleClearFilters = useCallback(() => { queueManager.filters.clearFilters(); }, [queueManager.filters]); // Keyboard shortcuts const { shortcuts } = useKeyboardShortcuts({ shortcuts: [ { key: '?', handler: () => setShowShortcutsHelp(true), description: 'Show keyboard shortcuts', }, { key: 'r', handler: () => queueManager.refresh(), description: 'Refresh queue', }, { key: 'k', ctrlOrCmd: true, handler: () => { // Focus search/filter (if implemented) document.querySelector('[data-filter-search]')?.focus(); }, description: 'Focus filters', }, { key: 'e', handler: () => { // Edit first claimed submission const claimedItem = queueManager.items.find(item => queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until) ); if (claimedItem) { handleOpenItemEditor(claimedItem.id); } }, description: 'Edit claimed submission', }, ], enabled: true, }); const handleOpenPhotos = (photos: PhotoItem[], index: number) => { setSelectedPhotos(photos); setSelectedPhotoIndex(index); setPhotoModalOpen(true); }; const handleOpenReviewManager = (submissionId: string) => { setSelectedSubmissionId(submissionId); setReviewManagerOpen(true); }; const handleOpenItemEditor = async (submissionId: string) => { try { const items = await fetchSubmissionItems(submissionId); if (!items || items.length === 0) { toast({ title: 'No Items Found', description: 'This submission has no items to edit', variant: 'destructive', }); return; } // Phase 3: Multi-item selector for submissions with multiple items if (items.length > 1) { setAvailableItems(items); setShowItemSelector(true); } else { // Single item - edit directly setEditingItem(items[0]); setShowItemEditDialog(true); } } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); } }; const handleSelectItem = (item: SubmissionItemWithDeps) => { setEditingItem(item); setShowItemSelector(false); setShowItemEditDialog(true); }; const handleBulkEdit = async (submissionId: string) => { try { const items = await fetchSubmissionItems(submissionId); if (!items || items.length === 0) { toast({ title: 'No Items Found', description: 'This submission has no items to edit', variant: 'destructive', }); return; } setBulkEditItems(items); setBulkEditMode(true); setShowItemEditDialog(true); } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); } }; // Expose imperative API useImperativeHandle(ref, () => ({ refresh: async () => { await queueManager.refresh(); } })); return (
{/* Offline Banner */} {isOffline && ( No Internet Connection You're offline. The moderation queue will automatically sync when your connection is restored. )} {/* Queue Statistics & Lock Status */} {queueManager.queue.queueStats && (
queueManager.queue.extendLock(queueManager.queue.currentLock?.submissionId || '')} onReleaseLock={() => queueManager.queue.releaseLock(queueManager.queue.currentLock?.submissionId || '', false)} getCurrentTime={() => new Date()} />
)} {/* Superuser Queue Controls */} {isSuperuser() && ( )} {/* Lock Restored Alert */} {lockRestored && queueManager.queue.currentLock && (() => { // Check if restored submission is in current queue const restoredSubmissionInQueue = queueManager.items.some( item => item.id === queueManager.queue.currentLock?.submissionId ); if (!restoredSubmissionInQueue) return null; // Calculate time remaining const timeRemainingMs = queueManager.queue.currentLock.expiresAt.getTime() - Date.now(); const timeRemainingSec = Math.max(0, Math.floor(timeRemainingMs / 1000)); const isExpiringSoon = timeRemainingSec < 300; // Less than 5 minutes return ( {isExpiringSoon ? `Lock Expiring Soon (${Math.floor(timeRemainingSec / 60)}m ${timeRemainingSec % 60}s)` : "Active Claim Restored" } {isExpiringSoon ? "Your lock is about to expire. Complete your review or extend the lock." : "Your previous claim was restored. You still have time to review this submission." } ); })()} {/* Filter Bar */} {/* Active Filters Display */} {queueManager.filters.hasActiveFilters && ( )} {/* 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.length <= 10 ? ( // Standard rendering for small lists (no virtual scrolling overhead)
{queueManager.items.map((item) => ( queueManager.markInteracting(id, true)} onInteractionBlur={(id) => queueManager.markInteracting(id, false)} onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined} /> ))}
) : ( // Virtual scrolling for large lists (10+ items)
{virtualizer.getVirtualItems().map((virtualItem) => { const item = queueManager.items[virtualItem.index]; return (
queueManager.markInteracting(id, true)} onInteractionBlur={(id) => queueManager.markInteracting(id, false)} onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined} />
); })}
)}
)} {/* Pagination */} {queueManager.loadingState === 'ready' && queueManager.pagination.totalPages > 1 && ( )} {/* Modals */} ({ ...photo, caption: photo.caption ?? undefined }))} initialIndex={selectedPhotoIndex} isOpen={photoModalOpen} onClose={() => setPhotoModalOpen(false)} /> {selectedSubmissionId && ( { queueManager.refresh(); setSelectedSubmissionId(null); }} /> )} {/* Phase 3: Item Selector Dialog */} { setShowItemSelector(false); setBulkEditItems(availableItems); setBulkEditMode(true); setShowItemEditDialog(true); }} /> {/* Phase 4 & 5: Enhanced Item Edit Dialog */} { setShowItemEditDialog(open); if (!open) { setEditingItem(null); setBulkEditMode(false); setBulkEditItems([]); } }} onComplete={() => { queueManager.refresh(); setEditingItem(null); setBulkEditMode(false); setBulkEditItems([]); }} /> {/* Confirmation Dialog */} setConfirmDialog(prev => ({ ...prev, open }))} title={confirmDialog.title} description={confirmDialog.description} onConfirm={confirmDialog.onConfirm} variant="destructive" confirmLabel="Delete" /> {/* Keyboard Shortcuts Help */}
); }); ModerationQueue.displayName = 'ModerationQueue'; export type { ModerationQueueRef } from '@/types/moderation';