diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index be7c54ae..181997f9 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef } from 'react'; -import { CheckCircle, XCircle, Filter, MessageSquare, FileText, Image, X, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap } from 'lucide-react'; +import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef, useMemo } from 'react'; +import { CheckCircle, XCircle, Filter, MessageSquare, FileText, Image, X, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap, ArrowUp, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; @@ -66,6 +66,13 @@ interface ModerationItem { type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' | 'approved' | 'rejected'; type QueueTab = 'mainQueue' | 'archive'; +type SortField = 'created_at' | 'username' | 'submission_type' | 'status' | 'escalated'; +type SortDirection = 'asc' | 'desc'; + +interface SortConfig { + field: SortField; + direction: SortDirection; +} export interface ModerationQueueRef { refresh: () => void; @@ -124,6 +131,19 @@ export const ModerationQueue = forwardRef((props, ref) => { 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 { @@ -159,6 +179,11 @@ export const ModerationQueue = forwardRef((props, ref) => { isAdminRef.current = isAdmin; isSuperuserRef.current = isSuperuser; }, [refreshStrategy, preserveInteraction, user, toast, isAdmin, isSuperuser]); + + // Persist sort configuration + useEffect(() => { + localStorage.setItem('moderationQueue_sortConfig', JSON.stringify(sortConfig)); + }, [sortConfig]); // Only sync itemsRef (not loadedIdsRef) to avoid breaking silent polling logic useEffect(() => { @@ -1731,6 +1756,57 @@ export const ModerationQueue = forwardRef((props, ref) => { } }; + // Sort items function + const sortItems = useCallback((items: ModerationItem[], config: SortConfig): ModerationItem[] => { + const sorted = [...items]; + + sorted.sort((a, b) => { + let compareA: any; + let compareB: any; + + switch (config.field) { + case 'created_at': + compareA = new Date(a.created_at).getTime(); + compareB = new Date(b.created_at).getTime(); + break; + + case 'username': + compareA = (a.user_profile?.username || '').toLowerCase(); + compareB = (b.user_profile?.username || '').toLowerCase(); + break; + + case 'submission_type': + compareA = a.submission_type || ''; + compareB = b.submission_type || ''; + break; + + case 'status': + compareA = a.status; + compareB = b.status; + break; + + case 'escalated': + compareA = a.escalated ? 1 : 0; + compareB = b.escalated ? 1 : 0; + break; + + default: + return 0; + } + + let result = 0; + if (typeof compareA === 'string' && typeof compareB === 'string') { + result = compareA.localeCompare(compareB); + } else if (typeof compareA === 'number' && typeof compareB === 'number') { + result = compareA - compareB; + } + + return config.direction === 'asc' ? result : -result; + }); + + return sorted; + }, []); + // Memoized callbacks const handleNoteChange = useCallback((id: string, value: string) => { setNotes(prev => ({ ...prev, [id]: value })); @@ -1777,13 +1853,18 @@ export const ModerationQueue = forwardRef((props, ref) => { ); } + // Apply client-side sorting + const sortedItems = useMemo(() => { + return sortItems(items, sortConfig); + }, [items, sortConfig]); + return (
- {items.map((item) => ( + {sortedItems.map((item) => ( ((props, ref) => { const clearFilters = () => { setActiveEntityFilter('all'); setActiveStatusFilter('pending'); + setSortConfig({ field: 'created_at', direction: 'asc' }); }; const getEntityFilterIcon = (filter: EntityFilter) => { @@ -1986,9 +2068,47 @@ export const ModerationQueue = forwardRef((props, ref) => {
+ +
+ +
+ + + +
+
- {(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && ( + {(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
{/* Active Filters Display */} - {(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && ( + {(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
Active filters: {activeEntityFilter !== 'all' && ( @@ -2018,6 +2138,15 @@ export const ModerationQueue = forwardRef((props, ref) => { {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'} + + )}
)} diff --git a/src/components/moderation/ReportsQueue.tsx b/src/components/moderation/ReportsQueue.tsx index 0b193d93..f4aeab02 100644 --- a/src/components/moderation/ReportsQueue.tsx +++ b/src/components/moderation/ReportsQueue.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react'; +import { useState, useEffect, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react'; +import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag, ArrowUp, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -52,6 +52,14 @@ const STATUS_COLORS = { dismissed: 'outline', } as const; +type ReportSortField = 'created_at' | 'reporter' | 'report_type' | 'entity_type'; +type ReportSortDirection = 'asc' | 'desc'; + +interface ReportSortConfig { + field: ReportSortField; + direction: ReportSortDirection; +} + export interface ReportsQueueRef { refresh: () => void; } @@ -71,6 +79,19 @@ export const ReportsQueue = forwardRef((props, ref) => { 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('reportsQueue_sortConfig'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return { field: 'created_at', direction: 'asc' as ReportSortDirection }; + } + } + return { field: 'created_at', direction: 'asc' as ReportSortDirection }; + }); // Get admin settings for polling configuration const { @@ -86,6 +107,11 @@ export const ReportsQueue = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ refresh: () => fetchReports(false) // Manual refresh shows loading }), []); + + // Persist sort configuration + useEffect(() => { + localStorage.setItem('reportsQueue_sortConfig', JSON.stringify(sortConfig)); + }, [sortConfig]); const fetchReports = async (silent = false) => { try { @@ -256,6 +282,52 @@ export const ReportsQueue = forwardRef((props, ref) => { } }; + // Sort reports function + const sortReports = useCallback((reports: Report[], config: ReportSortConfig): Report[] => { + const sorted = [...reports]; + + 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 'reporter': + compareA = (a.reporter_profile?.username || '').toLowerCase(); + compareB = (b.reporter_profile?.username || '').toLowerCase(); + break; + + case 'report_type': + compareA = a.report_type; + compareB = b.report_type; + break; + + case 'entity_type': + compareA = a.reported_entity_type; + compareB = b.reported_entity_type; + 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; + }, []); + if (loading) { return (
@@ -296,7 +368,61 @@ export const ReportsQueue = forwardRef((props, ref) => {
)} - {reports.map((report) => ( + {/* Sort Controls */} +
+
+ +
+ + + +
+
+ + {sortConfig.field !== 'created_at' && ( +
+ + {sortConfig.direction === 'asc' ? : } + {sortConfig.field === 'reporter' ? 'Reporter' : + sortConfig.field === 'report_type' ? 'Type' : + sortConfig.field === 'entity_type' ? 'Entity' : sortConfig.field} + +
+ )} +
+ + {/* Apply sorting before rendering */} + {useMemo(() => { + const sortedReports = sortReports(reports, sortConfig); + return sortedReports.map((report) => (