mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 05:11:14 -05:00
feat: Add pagination to moderation queues
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user