feat: Add pagination to moderation queues

This commit is contained in:
gpt-engineer-app[bot]
2025-10-10 16:59:30 +00:00
parent bc36583598
commit 74fbd116cb
2 changed files with 321 additions and 9 deletions

View File

@@ -5,6 +5,15 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -110,6 +119,12 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const initialFetchCompleteRef = useRef(false); const initialFetchCompleteRef = useRef(false);
const FETCH_COOLDOWN_MS = 1000; const FETCH_COOLDOWN_MS = 1000;
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const totalPages = Math.ceil(totalCount / pageSize);
// Get admin settings for polling configuration // Get admin settings for polling configuration
const { const {
getAdminPanelRefreshMode, getAdminPanelRefreshMode,
@@ -247,22 +262,59 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
submissionsQuery = submissionsQuery.neq('submission_type', 'photo'); submissionsQuery = submissionsQuery.neq('submission_type', 'photo');
} }
// Always fetch ALL pending/partially_approved submissions
// Let ID-based tracking determine what's "new" instead of timestamp filtering
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions // CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
// Admins see all submissions // Admins see all submissions
if (!isAdmin && !isSuperuser) { if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Show submissions that are:
// 1. Unclaimed (assigned_to is null)
// 2. Have expired locks (locked_until < now)
// 3. Are assigned to current user
submissionsQuery = submissionsQuery.or( submissionsQuery = submissionsQuery.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}` `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}`
); );
} }
// Get total count for pagination
const countQuery = supabase
.from('content_submissions')
.select('*', { count: 'exact', head: true });
// Apply same filters to count query
let countQueryWithFilters = countQuery;
if (tab === 'mainQueue') {
if (statusFilter === 'all') {
countQueryWithFilters = countQueryWithFilters.in('status', ['pending', 'flagged', 'partially_approved']);
} else if (statusFilter === 'pending') {
countQueryWithFilters = countQueryWithFilters.in('status', ['pending', 'partially_approved']);
} else {
countQueryWithFilters = countQueryWithFilters.eq('status', statusFilter);
}
} else {
if (statusFilter === 'all') {
countQueryWithFilters = countQueryWithFilters.in('status', ['approved', 'rejected']);
} else {
countQueryWithFilters = countQueryWithFilters.eq('status', statusFilter);
}
}
if (entityFilter === 'photos') {
countQueryWithFilters = countQueryWithFilters.eq('submission_type', 'photo');
} else if (entityFilter === 'submissions') {
countQueryWithFilters = countQueryWithFilters.neq('submission_type', 'photo');
}
if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString();
countQueryWithFilters = countQueryWithFilters.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}`
);
}
const { count } = await countQueryWithFilters;
setTotalCount(count || 0);
// Apply pagination range
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize - 1;
submissionsQuery = submissionsQuery.range(startIndex, endIndex);
const { data: submissions, error: submissionsError } = await submissionsQuery; const { data: submissions, error: submissionsError } = await submissionsQuery;
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
@@ -2014,6 +2066,117 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{/* Queue Content */} {/* Queue Content */}
<QueueContent /> <QueueContent />
{/* Pagination Controls */}
{totalPages > 1 && !loading && (
<div className="flex items-center justify-between border-t pt-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} items
</span>
{!isMobile && (
<>
<span></span>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 per page</SelectItem>
<SelectItem value="25">25 per page</SelectItem>
<SelectItem value="50">50 per page</SelectItem>
<SelectItem value="100">100 per page</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
{isMobile ? (
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
) : (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{currentPage > 3 && (
<>
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(1)} isActive={currentPage === 1}>
1
</PaginationLink>
</PaginationItem>
{currentPage > 4 && <PaginationEllipsis />}
</>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
.map(page => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
>
{page}
</PaginationLink>
</PaginationItem>
))
}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(totalPages)} isActive={currentPage === totalPages}>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)}
{/* Photo Modal */} {/* Photo Modal */}
<PhotoModal <PhotoModal
photos={selectedPhotos} photos={selectedPhotos}

View File

@@ -5,11 +5,22 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; 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 { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile';
import { smartMergeArray } from '@/lib/smartStateUpdate'; import { smartMergeArray } from '@/lib/smartStateUpdate';
interface Report { interface Report {
@@ -46,6 +57,7 @@ export interface ReportsQueueRef {
} }
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => { export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const isMobile = useIsMobile();
const [reports, setReports] = useState<Report[]>([]); const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true);
@@ -54,6 +66,12 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const totalPages = Math.ceil(totalCount / pageSize);
// Get admin settings for polling configuration // Get admin settings for polling configuration
const { const {
getAdminPanelRefreshMode, getAdminPanelRefreshMode,
@@ -76,6 +94,18 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
setLoading(true); setLoading(true);
} }
// Get total count
const { count } = await supabase
.from('reports')
.select('*', { count: 'exact', head: true })
.eq('status', 'pending');
setTotalCount(count || 0);
// Apply pagination
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize - 1;
const { data, error } = await supabase const { data, error } = await supabase
.from('reports') .from('reports')
.select(` .select(`
@@ -89,7 +119,8 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
reporter_id reporter_id
`) `)
.eq('status', 'pending') .eq('status', 'pending')
.order('created_at', { ascending: true }); .order('created_at', { ascending: true })
.range(startIndex, endIndex);
if (error) throw error; if (error) throw error;
@@ -205,7 +236,14 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
}); });
// Remove report from queue // Remove report from queue
setReports(prev => prev.filter(r => r.id !== reportId)); setReports(prev => {
const newReports = prev.filter(r => r.id !== reportId);
// If last item on page and not page 1, go to previous page
if (newReports.length === 0 && currentPage > 1) {
setCurrentPage(prev => prev - 1);
}
return newReports;
});
} catch (error) { } catch (error) {
console.error('Error updating report:', error); console.error('Error updating report:', error);
toast({ toast({
@@ -346,6 +384,117 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
</CardContent> </CardContent>
</Card> </Card>
))} ))}
{/* Pagination Controls */}
{totalPages > 1 && !loading && (
<div className="flex items-center justify-between border-t pt-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} reports
</span>
{!isMobile && (
<>
<span></span>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 per page</SelectItem>
<SelectItem value="25">25 per page</SelectItem>
<SelectItem value="50">50 per page</SelectItem>
<SelectItem value="100">100 per page</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
{isMobile ? (
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
) : (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{currentPage > 3 && (
<>
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(1)} isActive={currentPage === 1}>
1
</PaginationLink>
</PaginationItem>
{currentPage > 4 && <PaginationEllipsis />}
</>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
.map(page => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
>
{page}
</PaginationLink>
</PaginationItem>
))
}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(totalPages)} isActive={currentPage === totalPages}>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)}
</div> </div>
); );
}); });