diff --git a/src/components/moderation/ActiveFiltersDisplay.tsx b/src/components/moderation/ActiveFiltersDisplay.tsx new file mode 100644 index 00000000..174a9b03 --- /dev/null +++ b/src/components/moderation/ActiveFiltersDisplay.tsx @@ -0,0 +1,71 @@ +import { Filter, MessageSquare, FileText, Image, ArrowUp, ArrowDown } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import type { EntityFilter, StatusFilter, SortConfig, SortField } from '@/types/moderation'; + +interface ActiveFiltersDisplayProps { + entityFilter: EntityFilter; + statusFilter: StatusFilter; + sortConfig: SortConfig; + defaultEntityFilter?: EntityFilter; + defaultStatusFilter?: StatusFilter; + defaultSortField?: SortField; +} + +const getEntityFilterIcon = (filter: EntityFilter) => { + switch (filter) { + case 'reviews': return ; + case 'submissions': return ; + case 'photos': return ; + default: return ; + } +}; + +const getSortFieldLabel = (field: SortField): string => { + switch (field) { + case 'username': return 'Submitter'; + case 'submission_type': return 'Type'; + case 'escalated': return 'Escalated'; + case 'status': return 'Status'; + case 'created_at': return 'Date'; + default: return field; + } +}; + +export const ActiveFiltersDisplay = ({ + entityFilter, + statusFilter, + sortConfig, + defaultEntityFilter = 'all', + defaultStatusFilter = 'pending', + defaultSortField = 'created_at' +}: ActiveFiltersDisplayProps) => { + const hasActiveFilters = + entityFilter !== defaultEntityFilter || + statusFilter !== defaultStatusFilter || + sortConfig.field !== defaultSortField; + + if (!hasActiveFilters) return null; + + return ( +
+ Active filters: + {entityFilter !== defaultEntityFilter && ( + + {getEntityFilterIcon(entityFilter)} + {entityFilter} + + )} + {statusFilter !== defaultStatusFilter && ( + + {statusFilter} + + )} + {sortConfig.field !== defaultSortField && ( + + {sortConfig.direction === 'asc' ? : } + Sort: {getSortFieldLabel(sortConfig.field)} + + )} +
+ ); +}; diff --git a/src/components/moderation/AutoRefreshIndicator.tsx b/src/components/moderation/AutoRefreshIndicator.tsx new file mode 100644 index 00000000..5343132c --- /dev/null +++ b/src/components/moderation/AutoRefreshIndicator.tsx @@ -0,0 +1,24 @@ +interface AutoRefreshIndicatorProps { + enabled: boolean; + intervalSeconds: number; + mode?: 'polling' | 'realtime'; +} + +export const AutoRefreshIndicator = ({ + enabled, + intervalSeconds, + mode = 'polling' +}: AutoRefreshIndicatorProps) => { + if (!enabled) return null; + + return ( +
+
+
+ Auto-refresh active +
+ + Checking every {intervalSeconds}s +
+ ); +}; diff --git a/src/components/moderation/EmptyQueueState.tsx b/src/components/moderation/EmptyQueueState.tsx new file mode 100644 index 00000000..efbca20f --- /dev/null +++ b/src/components/moderation/EmptyQueueState.tsx @@ -0,0 +1,51 @@ +import { CheckCircle, LucideIcon } from 'lucide-react'; +import type { EntityFilter, StatusFilter } from '@/types/moderation'; + +interface EmptyQueueStateProps { + entityFilter: EntityFilter; + statusFilter: StatusFilter; + icon?: LucideIcon; + title?: string; + customMessage?: string; +} + +const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter): string => { + 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.`; + } +}; + +export const EmptyQueueState = ({ + entityFilter, + statusFilter, + icon: Icon = CheckCircle, + title = 'No items found', + customMessage +}: EmptyQueueStateProps) => { + const message = customMessage || getEmptyStateMessage(entityFilter, statusFilter); + + return ( +
+ +

{title}

+

{message}

+
+ ); +}; diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 570832ee..ad7bd76b 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,20 +1,6 @@ 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 { CheckCircle, XCircle, AlertTriangle, UserCog, Zap } from 'lucide-react'; 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'; @@ -25,8 +11,6 @@ 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'; @@ -35,6 +19,13 @@ import { QueueItem } from './QueueItem'; import { QueueSkeleton } from './QueueSkeleton'; import { LockStatusDisplay } from './LockStatusDisplay'; 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 { EmptyQueueState } from './EmptyQueueState'; +import { QueuePagination } from './QueuePagination'; import type { ModerationItem, EntityFilter, @@ -1818,28 +1809,6 @@ export const ModerationQueue = forwardRef((props, ref) => { } }; - 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[] => { @@ -1932,13 +1901,10 @@ export const ModerationQueue = forwardRef((props, ref) => { if (items.length === 0) { return ( -
- -

No items found

-

- {getEmptyStateMessage(activeEntityFilter, activeStatusFilter)} -

-
+ ); } @@ -2002,15 +1968,34 @@ export const ModerationQueue = forwardRef((props, ref) => { setSortConfig({ field: 'created_at', direction: 'asc' }); }; - const getEntityFilterIcon = (filter: EntityFilter) => { - switch (filter) { - case 'reviews': return ; - case 'submissions': return ; - case 'photos': return ; - default: return ; + 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(); @@ -2024,25 +2009,7 @@ export const ModerationQueue = forwardRef((props, ref) => {
- {/* Stats Grid */} -
-
-
{queue.queueStats.pendingCount}
-
Pending
-
-
-
{queue.queueStats.assignedToMe}
-
Assigned to Me
-
-
-
- {queue.queueStats.avgWaitHours.toFixed(1)}h -
-
Avg Wait
-
-
- - {/* Claim/Lock Status */} + ((props, ref) => { )} {/* 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 - - - -
- )} + {/* New Items Notification */} + {/* 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'} - /> - - - - )} -
+ {loadingState === 'ready' && ( + { + setLoadingState('loading'); + setCurrentPage(page); + }} + onPageSizeChange={(size) => { + setLoadingState('loading'); + setPageSize(size); + setCurrentPage(1); + }} + /> )} {/* Photo Modal */} diff --git a/src/components/moderation/NewItemsAlert.tsx b/src/components/moderation/NewItemsAlert.tsx new file mode 100644 index 00000000..8c5b8a89 --- /dev/null +++ b/src/components/moderation/NewItemsAlert.tsx @@ -0,0 +1,34 @@ +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; + +interface NewItemsAlertProps { + count: number; + onShowNewItems: () => void; + visible?: boolean; +} + +export const NewItemsAlert = ({ count, onShowNewItems, visible = true }: NewItemsAlertProps) => { + if (!visible || count === 0) return null; + + return ( +
+ + + New Items Available + + {count} new {count === 1 ? 'submission' : 'submissions'} pending review + + + +
+ ); +}; diff --git a/src/components/moderation/QueueFilters.tsx b/src/components/moderation/QueueFilters.tsx new file mode 100644 index 00000000..c47bee32 --- /dev/null +++ b/src/components/moderation/QueueFilters.tsx @@ -0,0 +1,140 @@ +import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { QueueSortControls } from './QueueSortControls'; +import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation'; + +interface QueueFiltersProps { + activeEntityFilter: EntityFilter; + activeStatusFilter: StatusFilter; + sortConfig: SortConfig; + isMobile: boolean; + onEntityFilterChange: (filter: EntityFilter) => void; + onStatusFilterChange: (filter: StatusFilter) => void; + onSortConfigChange: (config: SortConfig) => void; + onClearFilters: () => void; + showClearButton: boolean; +} + +const getEntityFilterIcon = (filter: EntityFilter) => { + switch (filter) { + case 'reviews': return ; + case 'submissions': return ; + case 'photos': return ; + default: return ; + } +}; + +export const QueueFilters = ({ + activeEntityFilter, + activeStatusFilter, + sortConfig, + isMobile, + onEntityFilterChange, + onStatusFilterChange, + onSortConfigChange, + onClearFilters, + showClearButton +}: QueueFiltersProps) => { + return ( +
+
+

Moderation Queue

+
+ +
+ {/* Entity Type Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+ + {/* Sort Controls */} + +
+ + {/* Clear Filters Button */} + {showClearButton && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/moderation/QueuePagination.tsx b/src/components/moderation/QueuePagination.tsx new file mode 100644 index 00000000..f1950808 --- /dev/null +++ b/src/components/moderation/QueuePagination.tsx @@ -0,0 +1,161 @@ +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; + +interface QueuePaginationProps { + currentPage: number; + totalPages: number; + pageSize: number; + totalCount: number; + isMobile: boolean; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; +} + +export const QueuePagination = ({ + currentPage, + totalPages, + pageSize, + totalCount, + isMobile, + onPageChange, + onPageSizeChange +}: QueuePaginationProps) => { + if (totalPages <= 1) return null; + + const handlePageChange = (page: number) => { + onPageChange(page); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handlePageSizeChange = (size: number) => { + onPageSizeChange(size); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const startItem = ((currentPage - 1) * pageSize) + 1; + const endItem = Math.min(currentPage * pageSize, totalCount); + + return ( +
+ {/* Item Count & Page Size Selector */} +
+ + Showing {startItem} - {endItem} of {totalCount} items + + {!isMobile && ( + <> + + + + )} +
+ + {/* Pagination Controls */} + {isMobile ? ( +
+ + + Page {currentPage} of {totalPages} + + +
+ ) : ( + + + + handlePageChange(Math.max(1, currentPage - 1))} + className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + {currentPage > 3 && ( + <> + + handlePageChange(1)} + isActive={currentPage === 1} + > + 1 + + + {currentPage > 4 && } + + )} + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter(page => page >= currentPage - 2 && page <= currentPage + 2) + .map(page => ( + + handlePageChange(page)} + isActive={currentPage === page} + > + {page} + + + )) + } + + {currentPage < totalPages - 2 && ( + <> + {currentPage < totalPages - 3 && } + + handlePageChange(totalPages)} + isActive={currentPage === totalPages} + > + {totalPages} + + + + )} + + + handlePageChange(Math.min(totalPages, currentPage + 1))} + className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + + )} +
+ ); +}; diff --git a/src/components/moderation/QueueSortControls.tsx b/src/components/moderation/QueueSortControls.tsx new file mode 100644 index 00000000..3e2dd352 --- /dev/null +++ b/src/components/moderation/QueueSortControls.tsx @@ -0,0 +1,84 @@ +import { ArrowUp, ArrowDown } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import type { SortConfig, SortField } from '@/types/moderation'; + +interface QueueSortControlsProps { + sortConfig: SortConfig; + onSortChange: (config: SortConfig) => void; + isMobile?: boolean; + variant?: 'inline' | 'standalone'; + showLabel?: boolean; +} + +const getSortFieldLabel = (field: SortField): string => { + switch (field) { + case 'created_at': return 'Date Created'; + case 'username': return 'Submitter'; + case 'submission_type': return 'Type'; + case 'status': return 'Status'; + case 'escalated': return 'Escalated'; + default: return field; + } +}; + +export const QueueSortControls = ({ + sortConfig, + onSortChange, + isMobile = false, + variant = 'inline', + showLabel = true +}: QueueSortControlsProps) => { + const handleFieldChange = (field: SortField) => { + onSortChange({ ...sortConfig, field }); + }; + + const handleDirectionToggle = () => { + onSortChange({ + ...sortConfig, + direction: sortConfig.direction === 'asc' ? 'desc' : 'asc' + }); + }; + + return ( +
+ {showLabel && ( + + )} +
+ + + +
+
+ ); +}; diff --git a/src/components/moderation/QueueStats.tsx b/src/components/moderation/QueueStats.tsx new file mode 100644 index 00000000..ad756dce --- /dev/null +++ b/src/components/moderation/QueueStats.tsx @@ -0,0 +1,29 @@ +interface QueueStatsProps { + stats: { + pendingCount: number; + assignedToMe: number; + avgWaitHours: number; + }; + isMobile?: boolean; +} + +export const QueueStats = ({ stats, isMobile }: QueueStatsProps) => { + return ( +
+
+
{stats.pendingCount}
+
Pending
+
+
+
{stats.assignedToMe}
+
Assigned to Me
+
+
+
+ {stats.avgWaitHours.toFixed(1)}h +
+
Avg Wait
+
+
+ ); +};