feat: Implement client-side sorting for queues

This commit is contained in:
gpt-engineer-app[bot]
2025-10-10 17:06:39 +00:00
parent 74fbd116cb
commit 83ccc51f61
2 changed files with 263 additions and 8 deletions

View File

@@ -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<ModerationQueueRef>((props, ref) => {
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const totalPages = Math.ceil(totalCount / pageSize);
// Sort state
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
);
}
// Apply client-side sorting
const sortedItems = useMemo(() => {
return sortItems(items, sortConfig);
}, [items, sortConfig]);
return (
<div
className="flex flex-col gap-6"
data-initial-load={!hasRenderedOnce ? "true" : "false"}
style={{ willChange: 'transform' }}
>
{items.map((item) => (
{sortedItems.map((item) => (
<QueueItem
key={item.id}
item={item}
@@ -1815,6 +1896,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
</SelectContent>
</Select>
</div>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[180px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Sort By</Label>
<div className="flex gap-2">
<Select
value={sortConfig.field}
onValueChange={(value) => setSortConfig(prev => ({ ...prev, field: value as SortField }))}
>
<SelectTrigger className={isMobile ? "h-10 flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Date Created</SelectItem>
<SelectItem value="username">Submitter</SelectItem>
<SelectItem value="submission_type">Type</SelectItem>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="escalated">Escalated</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
onClick={() => setSortConfig(prev => ({
...prev,
direction: prev.direction === 'asc' ? 'desc' : 'asc'
}))}
className={isMobile ? "h-10" : ""}
title={sortConfig.direction === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
>
{sortConfig.direction === 'asc' ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
<div className={isMobile ? "" : "flex items-end"}>
<Button
variant="outline"
@@ -2004,7 +2124,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
</div>
{/* Active Filters Display */}
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Active filters:</span>
{activeEntityFilter !== 'all' && (
@@ -2018,6 +2138,15 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{activeStatusFilter}
</Badge>
)}
{sortConfig.field !== 'created_at' && (
<Badge variant="secondary" className="flex items-center gap-1">
{sortConfig.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
Sort: {sortConfig.field === 'username' ? 'Submitter' :
sortConfig.field === 'submission_type' ? 'Type' :
sortConfig.field === 'escalated' ? 'Escalated' :
sortConfig.field === 'status' ? 'Status' : 'Date'}
</Badge>
)}
</div>
)}

View File

@@ -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<ReportsQueueRef>((props, ref) => {
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const totalPages = Math.ceil(totalCount / pageSize);
// Sort state
const [sortConfig, setSortConfig] = useState<ReportSortConfig>(() => {
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<ReportsQueueRef>((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<ReportsQueueRef>((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 (
<div className="flex items-center justify-center p-8">
@@ -296,7 +368,61 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
</div>
)}
{reports.map((report) => (
{/* Sort Controls */}
<div className={`flex gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'flex-1 max-w-[200px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Sort By</Label>
<div className="flex gap-2">
<Select
value={sortConfig.field}
onValueChange={(value) => setSortConfig(prev => ({ ...prev, field: value as ReportSortField }))}
>
<SelectTrigger className={isMobile ? "h-10 flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Date Reported</SelectItem>
<SelectItem value="reporter">Reporter</SelectItem>
<SelectItem value="report_type">Report Type</SelectItem>
<SelectItem value="entity_type">Entity Type</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
onClick={() => setSortConfig(prev => ({
...prev,
direction: prev.direction === 'asc' ? 'desc' : 'asc'
}))}
className={isMobile ? "h-10" : ""}
title={sortConfig.direction === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
>
{sortConfig.direction === 'asc' ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
{sortConfig.field !== 'created_at' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="flex items-center gap-1">
{sortConfig.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
{sortConfig.field === 'reporter' ? 'Reporter' :
sortConfig.field === 'report_type' ? 'Type' :
sortConfig.field === 'entity_type' ? 'Entity' : sortConfig.field}
</Badge>
</div>
)}
</div>
{/* Apply sorting before rendering */}
{useMemo(() => {
const sortedReports = sortReports(reports, sortConfig);
return sortedReports.map((report) => (
<Card key={report.id} className="border-l-4 border-l-red-500">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">