mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 22:11:11 -05:00
feat: Implement client-side sorting for queues
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef } from '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 } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -66,6 +66,13 @@ interface ModerationItem {
|
|||||||
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
||||||
type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' | 'approved' | 'rejected';
|
type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' | 'approved' | 'rejected';
|
||||||
type QueueTab = 'mainQueue' | 'archive';
|
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 {
|
export interface ModerationQueueRef {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
@@ -124,6 +131,19 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const totalPages = Math.ceil(totalCount / pageSize);
|
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
|
// Get admin settings for polling configuration
|
||||||
const {
|
const {
|
||||||
@@ -159,6 +179,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
isAdminRef.current = isAdmin;
|
isAdminRef.current = isAdmin;
|
||||||
isSuperuserRef.current = isSuperuser;
|
isSuperuserRef.current = isSuperuser;
|
||||||
}, [refreshStrategy, preserveInteraction, user, toast, isAdmin, 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
|
// Only sync itemsRef (not loadedIdsRef) to avoid breaking silent polling logic
|
||||||
useEffect(() => {
|
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
|
// Memoized callbacks
|
||||||
const handleNoteChange = useCallback((id: string, value: string) => {
|
const handleNoteChange = useCallback((id: string, value: string) => {
|
||||||
setNotes(prev => ({ ...prev, [id]: value }));
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
data-initial-load={!hasRenderedOnce ? "true" : "false"}
|
data-initial-load={!hasRenderedOnce ? "true" : "false"}
|
||||||
style={{ willChange: 'transform' }}
|
style={{ willChange: 'transform' }}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<QueueItem
|
<QueueItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -1815,6 +1896,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setActiveEntityFilter('all');
|
setActiveEntityFilter('all');
|
||||||
setActiveStatusFilter('pending');
|
setActiveStatusFilter('pending');
|
||||||
|
setSortConfig({ field: 'created_at', direction: 'asc' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEntityFilterIcon = (filter: EntityFilter) => {
|
const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||||
@@ -1986,9 +2068,47 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
|
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
|
||||||
<div className={isMobile ? "" : "flex items-end"}>
|
<div className={isMobile ? "" : "flex items-end"}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -2004,7 +2124,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
{/* 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">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>Active filters:</span>
|
<span>Active filters:</span>
|
||||||
{activeEntityFilter !== 'all' && (
|
{activeEntityFilter !== 'all' && (
|
||||||
@@ -2018,6 +2138,15 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
{activeStatusFilter}
|
{activeStatusFilter}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react';
|
||||||
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react';
|
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
@@ -52,6 +52,14 @@ const STATUS_COLORS = {
|
|||||||
dismissed: 'outline',
|
dismissed: 'outline',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
type ReportSortField = 'created_at' | 'reporter' | 'report_type' | 'entity_type';
|
||||||
|
type ReportSortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
interface ReportSortConfig {
|
||||||
|
field: ReportSortField;
|
||||||
|
direction: ReportSortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportsQueueRef {
|
export interface ReportsQueueRef {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,19 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
|||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const totalPages = Math.ceil(totalCount / pageSize);
|
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
|
// Get admin settings for polling configuration
|
||||||
const {
|
const {
|
||||||
@@ -86,6 +107,11 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
refresh: () => fetchReports(false) // Manual refresh shows loading
|
refresh: () => fetchReports(false) // Manual refresh shows loading
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
// Persist sort configuration
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('reportsQueue_sortConfig', JSON.stringify(sortConfig));
|
||||||
|
}, [sortConfig]);
|
||||||
|
|
||||||
const fetchReports = async (silent = false) => {
|
const fetchReports = async (silent = false) => {
|
||||||
try {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@@ -296,7 +368,61 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
|||||||
</div>
|
</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">
|
<Card key={report.id} className="border-l-4 border-l-red-500">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user