mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:11:13 -05:00
Refactor moderation queues
This commit is contained in:
@@ -14,12 +14,11 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { PhotoModal } from './PhotoModal';
|
import { PhotoModal } from './PhotoModal';
|
||||||
import { SubmissionReviewManager } from './SubmissionReviewManager';
|
import { SubmissionReviewManager } from './SubmissionReviewManager';
|
||||||
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
|
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
||||||
import { SubmissionItemsList } from './SubmissionItemsList';
|
import { SubmissionItemsList } from './SubmissionItemsList';
|
||||||
import { RealtimeConnectionStatus } from './RealtimeConnectionStatus';
|
|
||||||
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||||
|
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||||
|
|
||||||
interface ModerationItem {
|
interface ModerationItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +69,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const { isAdmin, isSuperuser } = useUserRole();
|
const { isAdmin, isSuperuser } = useUserRole();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Get admin settings for polling configuration
|
||||||
|
const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings();
|
||||||
|
const refreshMode = getAdminPanelRefreshMode();
|
||||||
|
const pollInterval = getAdminPanelPollInterval();
|
||||||
|
|
||||||
// Expose refresh method via ref
|
// Expose refresh method via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
@@ -346,116 +350,26 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up realtime subscriptions
|
// Initial fetch on mount and filter changes
|
||||||
const { connectionState: submissionsConnectionState, reconnect: reconnectSubmissions } = useRealtimeSubmissions({
|
|
||||||
onInsert: async (payload) => {
|
|
||||||
const newSubmission = payload.new;
|
|
||||||
|
|
||||||
// Only add if it matches current filters
|
|
||||||
const matchesStatusFilter =
|
|
||||||
activeStatusFilter === 'all' ||
|
|
||||||
(activeStatusFilter === 'pending' && (newSubmission.status === 'pending' || newSubmission.status === 'partially_approved')) ||
|
|
||||||
activeStatusFilter === newSubmission.status;
|
|
||||||
|
|
||||||
const matchesEntityFilter =
|
|
||||||
activeEntityFilter === 'all' ||
|
|
||||||
(activeEntityFilter === 'submissions' && newSubmission.submission_type !== 'photo') ||
|
|
||||||
(activeEntityFilter === 'photos' && newSubmission.submission_type === 'photo');
|
|
||||||
|
|
||||||
if (!matchesStatusFilter || !matchesEntityFilter) return;
|
|
||||||
|
|
||||||
// Fetch minimal data for the new submission
|
|
||||||
try {
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('user_id, username, display_name, avatar_url')
|
|
||||||
.eq('user_id', newSubmission.user_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// Fetch entity name if photo submission
|
|
||||||
let entity_name, park_name;
|
|
||||||
if (newSubmission.submission_type === 'photo' && newSubmission.content) {
|
|
||||||
const contentObj = newSubmission.content as any;
|
|
||||||
const contextType = typeof contentObj.context === 'string' ? contentObj.context : null;
|
|
||||||
const entityId = contentObj.entity_id || contentObj.ride_id || contentObj.park_id || contentObj.company_id;
|
|
||||||
|
|
||||||
if (contextType === 'ride' && entityId) {
|
|
||||||
const { data: rideData } = await supabase
|
|
||||||
.from('rides')
|
|
||||||
.select('name, parks:park_id(name)')
|
|
||||||
.eq('id', entityId)
|
|
||||||
.single();
|
|
||||||
if (rideData) {
|
|
||||||
entity_name = rideData.name;
|
|
||||||
park_name = rideData.parks?.name;
|
|
||||||
}
|
|
||||||
} else if (contextType === 'park' && entityId) {
|
|
||||||
const { data: parkData } = await supabase
|
|
||||||
.from('parks')
|
|
||||||
.select('name')
|
|
||||||
.eq('id', entityId)
|
|
||||||
.single();
|
|
||||||
if (parkData) entity_name = parkData.name;
|
|
||||||
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) {
|
|
||||||
const { data: companyData } = await supabase
|
|
||||||
.from('companies')
|
|
||||||
.select('name')
|
|
||||||
.eq('id', entityId)
|
|
||||||
.single();
|
|
||||||
if (companyData) entity_name = companyData.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new item and prepend to list
|
|
||||||
const newItem: ModerationItem = {
|
|
||||||
id: newSubmission.id,
|
|
||||||
type: 'content_submission',
|
|
||||||
content: newSubmission.submission_type === 'photo' ? newSubmission.content : newSubmission,
|
|
||||||
created_at: newSubmission.created_at,
|
|
||||||
user_id: newSubmission.user_id,
|
|
||||||
status: newSubmission.status,
|
|
||||||
submission_type: newSubmission.submission_type,
|
|
||||||
user_profile: profile || undefined,
|
|
||||||
entity_name,
|
|
||||||
park_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
setItems(prevItems => [newItem, ...prevItems]);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'New Submission',
|
|
||||||
description: 'A new content submission has been added',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding new submission to queue:', error);
|
|
||||||
// Fallback to full refresh on error
|
|
||||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate: (payload) => {
|
|
||||||
// Update items state directly for better UX
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === payload.new.id && item.type === 'content_submission'
|
|
||||||
? { ...item, status: payload.new.status, content: { ...item.content, ...payload.new } }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDelete: (payload) => {
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.filter(item => !(item.id === payload.old.id && item.type === 'content_submission'))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||||
}
|
}
|
||||||
}, [activeEntityFilter, activeStatusFilter, user]);
|
}, [activeEntityFilter, activeStatusFilter, user]);
|
||||||
|
|
||||||
|
// Polling for auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || refreshMode !== 'auto') return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||||
|
}, pollInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter]);
|
||||||
|
|
||||||
const handleResetToPending = async (item: ModerationItem) => {
|
const handleResetToPending = async (item: ModerationItem) => {
|
||||||
setActionLoading(item.id);
|
setActionLoading(item.id);
|
||||||
try {
|
try {
|
||||||
@@ -1765,10 +1679,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
||||||
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
||||||
<RealtimeConnectionStatus
|
|
||||||
connectionState={submissionsConnectionState}
|
|
||||||
onReconnect={reconnectSubmissions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { RefreshCw, Wifi, WifiOff, AlertCircle } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
||||||
|
|
||||||
interface RealtimeConnectionStatusProps {
|
|
||||||
connectionState: ConnectionState;
|
|
||||||
onReconnect: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RealtimeConnectionStatus({
|
|
||||||
connectionState,
|
|
||||||
onReconnect,
|
|
||||||
className = '',
|
|
||||||
}: RealtimeConnectionStatusProps) {
|
|
||||||
const getStatusConfig = () => {
|
|
||||||
switch (connectionState) {
|
|
||||||
case 'connected':
|
|
||||||
return {
|
|
||||||
icon: Wifi,
|
|
||||||
color: 'text-green-500',
|
|
||||||
label: 'Connected',
|
|
||||||
description: 'Live updates active',
|
|
||||||
showReconnect: false,
|
|
||||||
};
|
|
||||||
case 'connecting':
|
|
||||||
return {
|
|
||||||
icon: RefreshCw,
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
label: 'Connecting',
|
|
||||||
description: 'Establishing connection...',
|
|
||||||
showReconnect: false,
|
|
||||||
animate: 'animate-spin',
|
|
||||||
};
|
|
||||||
case 'error':
|
|
||||||
return {
|
|
||||||
icon: AlertCircle,
|
|
||||||
color: 'text-red-500',
|
|
||||||
label: 'Error',
|
|
||||||
description: 'Connection failed. Retrying...',
|
|
||||||
showReconnect: true,
|
|
||||||
};
|
|
||||||
case 'disconnected':
|
|
||||||
return {
|
|
||||||
icon: WifiOff,
|
|
||||||
color: 'text-muted-foreground',
|
|
||||||
label: 'Disconnected',
|
|
||||||
description: 'Live updates unavailable',
|
|
||||||
showReconnect: true,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: WifiOff,
|
|
||||||
color: 'text-muted-foreground',
|
|
||||||
label: 'Unknown',
|
|
||||||
description: 'Connection status unknown',
|
|
||||||
showReconnect: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getStatusConfig();
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-2 ${className}`}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon
|
|
||||||
className={`w-4 h-4 ${config.color} ${config.animate || ''}`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{config.description}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{config.showReconnect && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onReconnect}
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
|
||||||
Reconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react';
|
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } 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';
|
||||||
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
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 { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
interface Report {
|
interface Report {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,11 +40,26 @@ const STATUS_COLORS = {
|
|||||||
dismissed: 'outline',
|
dismissed: 'outline',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function ReportsQueue() {
|
export interface ReportsQueueRef {
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
||||||
const [reports, setReports] = useState<Report[]>([]);
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Get admin settings for polling configuration
|
||||||
|
const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings();
|
||||||
|
const refreshMode = getAdminPanelRefreshMode();
|
||||||
|
const pollInterval = getAdminPanelPollInterval();
|
||||||
|
|
||||||
|
// Expose refresh method via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
refresh: fetchReports
|
||||||
|
}), []);
|
||||||
|
|
||||||
const fetchReports = async () => {
|
const fetchReports = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -110,9 +127,25 @@ export function ReportsQueue() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
fetchReports();
|
fetchReports();
|
||||||
}, []);
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Polling for auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || refreshMode !== 'auto') return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchReports();
|
||||||
|
}, pollInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [user, refreshMode, pollInterval]);
|
||||||
|
|
||||||
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
|
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
|
||||||
setActionLoading(reportId);
|
setActionLoading(reportId);
|
||||||
@@ -258,4 +291,4 @@ export function ReportsQueue() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
|
|
||||||
import {
|
import {
|
||||||
fetchSubmissionItems,
|
fetchSubmissionItems,
|
||||||
buildDependencyTree,
|
buildDependencyTree,
|
||||||
@@ -60,20 +59,6 @@ export function SubmissionReviewManager({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const Container = isMobile ? Sheet : Dialog;
|
const Container = isMobile ? Sheet : Dialog;
|
||||||
|
|
||||||
// Set up realtime subscription for submission items
|
|
||||||
useRealtimeSubmissionItems({
|
|
||||||
submissionId,
|
|
||||||
onUpdate: (payload) => {
|
|
||||||
console.log('Submission item updated in real-time:', payload);
|
|
||||||
toast({
|
|
||||||
title: 'Item Updated',
|
|
||||||
description: 'A submission item was updated by another moderator',
|
|
||||||
});
|
|
||||||
loadSubmissionItems();
|
|
||||||
},
|
|
||||||
enabled: open && !!submissionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && submissionId) {
|
if (open && submissionId) {
|
||||||
loadSubmissionItems();
|
loadSubmissionItems();
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
|
||||||
import { useUserRole } from './useUserRole';
|
|
||||||
|
|
||||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
||||||
|
|
||||||
interface ModerationStats {
|
|
||||||
pendingSubmissions: number;
|
|
||||||
openReports: number;
|
|
||||||
flaggedContent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseRealtimeModerationStatsOptions {
|
|
||||||
onStatsChange?: (stats: ModerationStats) => void;
|
|
||||||
enabled?: boolean;
|
|
||||||
debounceMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRealtimeModerationStats = (options: UseRealtimeModerationStatsOptions = {}) => {
|
|
||||||
const { onStatsChange, enabled = true, debounceMs = 1000 } = options;
|
|
||||||
const { isModerator, loading: roleLoading } = useUserRole();
|
|
||||||
const [stats, setStats] = useState<ModerationStats>({
|
|
||||||
pendingSubmissions: 0,
|
|
||||||
openReports: 0,
|
|
||||||
flaggedContent: 0,
|
|
||||||
});
|
|
||||||
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
|
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
||||||
const updateTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const onStatsChangeRef = useRef(onStatsChange);
|
|
||||||
|
|
||||||
// Only enable realtime when user is confirmed as moderator
|
|
||||||
const realtimeEnabled = enabled && !roleLoading && isModerator();
|
|
||||||
|
|
||||||
// Update ref when callback changes
|
|
||||||
useEffect(() => {
|
|
||||||
onStatsChangeRef.current = onStatsChange;
|
|
||||||
}, [onStatsChange]);
|
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('id', { count: 'exact', head: true })
|
|
||||||
.eq('status', 'pending'),
|
|
||||||
supabase
|
|
||||||
.from('reports')
|
|
||||||
.select('id', { count: 'exact', head: true })
|
|
||||||
.eq('status', 'pending'),
|
|
||||||
supabase
|
|
||||||
.from('reviews')
|
|
||||||
.select('id', { count: 'exact', head: true })
|
|
||||||
.eq('moderation_status', 'flagged'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const newStats = {
|
|
||||||
pendingSubmissions: submissionsResult.count || 0,
|
|
||||||
openReports: reportsResult.count || 0,
|
|
||||||
flaggedContent: reviewsResult.count || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
setStats(newStats);
|
|
||||||
onStatsChangeRef.current?.(newStats);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching moderation stats:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debouncedFetchStats = useCallback(() => {
|
|
||||||
if (updateTimerRef.current) {
|
|
||||||
clearTimeout(updateTimerRef.current);
|
|
||||||
}
|
|
||||||
updateTimerRef.current = setTimeout(fetchStats, debounceMs);
|
|
||||||
}, [fetchStats, debounceMs]);
|
|
||||||
|
|
||||||
const reconnect = useCallback(() => {
|
|
||||||
if (channel) {
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
}
|
|
||||||
setConnectionState('connecting');
|
|
||||||
fetchStats();
|
|
||||||
}, [channel, fetchStats]);
|
|
||||||
|
|
||||||
// Initial fetch and polling fallback
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
fetchStats();
|
|
||||||
|
|
||||||
let pollInterval: NodeJS.Timeout | null = null;
|
|
||||||
if (connectionState !== 'connected') {
|
|
||||||
pollInterval = setInterval(fetchStats, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
}
|
|
||||||
if (updateTimerRef.current) {
|
|
||||||
clearTimeout(updateTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [enabled, fetchStats, connectionState]);
|
|
||||||
|
|
||||||
// Set up broadcast channels for real-time updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!realtimeEnabled) {
|
|
||||||
console.log('[Realtime:moderation-stats] Realtime disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Realtime:moderation-stats] Creating broadcast channels');
|
|
||||||
setConnectionState('connecting');
|
|
||||||
|
|
||||||
const setupChannels = async () => {
|
|
||||||
// Set auth token for private channels
|
|
||||||
await supabase.realtime.setAuth();
|
|
||||||
|
|
||||||
const submissionsChannel = supabase
|
|
||||||
.channel('moderation:content_submissions', {
|
|
||||||
config: { private: true },
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'INSERT' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Content submission inserted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'UPDATE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Content submission updated');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'DELETE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Content submission deleted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.subscribe((status) => {
|
|
||||||
console.log('[Realtime:moderation-stats] Submissions channel status:', status);
|
|
||||||
|
|
||||||
if (status === 'SUBSCRIBED') {
|
|
||||||
setConnectionState('connected');
|
|
||||||
} else if (status === 'CHANNEL_ERROR') {
|
|
||||||
setConnectionState('error');
|
|
||||||
} else if (status === 'TIMED_OUT') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
console.log('[Realtime:moderation-stats] Falling back to polling');
|
|
||||||
} else if (status === 'CLOSED') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const reportsChannel = supabase
|
|
||||||
.channel('moderation:reports', {
|
|
||||||
config: { private: true },
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'INSERT' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Report inserted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'UPDATE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Report updated');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'DELETE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Report deleted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
const reviewsChannel = supabase
|
|
||||||
.channel('moderation:reviews', {
|
|
||||||
config: { private: true },
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'INSERT' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Review inserted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'UPDATE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Review updated');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'DELETE' }, () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Review deleted');
|
|
||||||
debouncedFetchStats();
|
|
||||||
})
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
setChannel(submissionsChannel);
|
|
||||||
|
|
||||||
return { submissionsChannel, reportsChannel, reviewsChannel };
|
|
||||||
};
|
|
||||||
|
|
||||||
let channels: Awaited<ReturnType<typeof setupChannels>>;
|
|
||||||
setupChannels().then((c) => {
|
|
||||||
channels = c;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log('[Realtime:moderation-stats] Cleaning up channels');
|
|
||||||
if (channels) {
|
|
||||||
supabase.removeChannel(channels.submissionsChannel);
|
|
||||||
supabase.removeChannel(channels.reportsChannel);
|
|
||||||
supabase.removeChannel(channels.reviewsChannel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [realtimeEnabled, debouncedFetchStats]);
|
|
||||||
|
|
||||||
return { stats, refresh: fetchStats, connectionState, reconnect };
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
||||||
|
|
||||||
interface UseRealtimeSubmissionItemsOptions {
|
|
||||||
submissionId?: string;
|
|
||||||
onUpdate?: (payload: any) => void;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRealtimeSubmissionItems = (options: UseRealtimeSubmissionItemsOptions = {}) => {
|
|
||||||
const { submissionId, onUpdate, enabled = true } = options;
|
|
||||||
|
|
||||||
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
|
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
||||||
|
|
||||||
// Use ref to store latest callback without triggering re-subscriptions
|
|
||||||
const onUpdateRef = useRef(onUpdate);
|
|
||||||
|
|
||||||
// Update ref when callback changes
|
|
||||||
useEffect(() => {
|
|
||||||
onUpdateRef.current = onUpdate;
|
|
||||||
}, [onUpdate]);
|
|
||||||
|
|
||||||
const reconnect = useCallback(() => {
|
|
||||||
if (channel) {
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
}
|
|
||||||
setConnectionState('connecting');
|
|
||||||
}, [channel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !submissionId) {
|
|
||||||
console.log('[Realtime:submission-items] Disabled or no submission ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Realtime:submission-items] Creating new broadcast channel for submission:', submissionId);
|
|
||||||
setConnectionState('connecting');
|
|
||||||
|
|
||||||
const setupChannel = async () => {
|
|
||||||
// Set auth token for private channel
|
|
||||||
await supabase.realtime.setAuth();
|
|
||||||
|
|
||||||
const newChannel = supabase
|
|
||||||
.channel('moderation:submission_items', {
|
|
||||||
config: { private: true },
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'UPDATE' }, (payload) => {
|
|
||||||
// Client-side filtering for specific submission
|
|
||||||
const itemData = payload.payload;
|
|
||||||
if (itemData?.new?.submission_id === submissionId) {
|
|
||||||
console.log('Submission item updated:', payload);
|
|
||||||
onUpdateRef.current?.(payload);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.subscribe((status) => {
|
|
||||||
console.log(`[Realtime:submission-items] Subscription status:`, status);
|
|
||||||
|
|
||||||
if (status === 'SUBSCRIBED') {
|
|
||||||
setConnectionState('connected');
|
|
||||||
} else if (status === 'CHANNEL_ERROR') {
|
|
||||||
setConnectionState('error');
|
|
||||||
} else if (status === 'TIMED_OUT') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
} else if (status === 'CLOSED') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setChannel(newChannel);
|
|
||||||
};
|
|
||||||
|
|
||||||
setupChannel();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (channel) {
|
|
||||||
console.log('[Realtime:submission-items] Cleaning up channel');
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [enabled, submissionId]);
|
|
||||||
|
|
||||||
return { channel, connectionState, reconnect };
|
|
||||||
};
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
|
||||||
import { useUserRole } from './useUserRole';
|
|
||||||
|
|
||||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
||||||
|
|
||||||
interface UseRealtimeSubmissionsOptions {
|
|
||||||
onInsert?: (payload: any) => void;
|
|
||||||
onUpdate?: (payload: any) => void;
|
|
||||||
onDelete?: (payload: any) => void;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRealtimeSubmissions = (options: UseRealtimeSubmissionsOptions = {}) => {
|
|
||||||
const { onInsert, onUpdate, onDelete, enabled = true } = options;
|
|
||||||
const { isModerator, loading: roleLoading } = useUserRole();
|
|
||||||
|
|
||||||
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
|
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
||||||
|
|
||||||
// Only enable realtime when user is confirmed as moderator
|
|
||||||
const realtimeEnabled = enabled && !roleLoading && isModerator();
|
|
||||||
|
|
||||||
// Use refs to store latest callbacks without triggering re-subscriptions
|
|
||||||
const onInsertRef = useRef(onInsert);
|
|
||||||
const onUpdateRef = useRef(onUpdate);
|
|
||||||
const onDeleteRef = useRef(onDelete);
|
|
||||||
|
|
||||||
// Update refs when callbacks change
|
|
||||||
useEffect(() => {
|
|
||||||
onInsertRef.current = onInsert;
|
|
||||||
onUpdateRef.current = onUpdate;
|
|
||||||
onDeleteRef.current = onDelete;
|
|
||||||
}, [onInsert, onUpdate, onDelete]);
|
|
||||||
|
|
||||||
const reconnect = useCallback(() => {
|
|
||||||
if (channel) {
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
}
|
|
||||||
setConnectionState('connecting');
|
|
||||||
}, [channel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!realtimeEnabled) {
|
|
||||||
console.log('[Realtime:content-submissions] Realtime disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Realtime:content-submissions] Creating new broadcast channel');
|
|
||||||
setConnectionState('connecting');
|
|
||||||
|
|
||||||
const setupChannel = async () => {
|
|
||||||
// Set auth token for private channel
|
|
||||||
await supabase.realtime.setAuth();
|
|
||||||
|
|
||||||
const newChannel = supabase
|
|
||||||
.channel('moderation:content_submissions', {
|
|
||||||
config: { private: true },
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'INSERT' }, (payload) => {
|
|
||||||
console.log('Submission inserted:', payload);
|
|
||||||
onInsertRef.current?.(payload);
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'UPDATE' }, (payload) => {
|
|
||||||
console.log('Submission updated:', payload);
|
|
||||||
onUpdateRef.current?.(payload);
|
|
||||||
})
|
|
||||||
.on('broadcast', { event: 'DELETE' }, (payload) => {
|
|
||||||
console.log('Submission deleted:', payload);
|
|
||||||
onDeleteRef.current?.(payload);
|
|
||||||
})
|
|
||||||
.subscribe((status) => {
|
|
||||||
console.log('[Realtime:content-submissions] Subscription status:', status);
|
|
||||||
|
|
||||||
if (status === 'SUBSCRIBED') {
|
|
||||||
setConnectionState('connected');
|
|
||||||
} else if (status === 'CHANNEL_ERROR') {
|
|
||||||
setConnectionState('error');
|
|
||||||
} else if (status === 'TIMED_OUT') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
} else if (status === 'CLOSED') {
|
|
||||||
setConnectionState('disconnected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setChannel(newChannel);
|
|
||||||
};
|
|
||||||
|
|
||||||
setupChannel();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (channel) {
|
|
||||||
console.log('[Realtime:content-submissions] Cleaning up channel');
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [realtimeEnabled]);
|
|
||||||
|
|
||||||
return { channel, connectionState, reconnect };
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
|
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
|
||||||
import { ReportsQueue } from '@/components/moderation/ReportsQueue';
|
import { ReportsQueue, ReportsQueueRef } from '@/components/moderation/ReportsQueue';
|
||||||
import { UserManagement } from '@/components/admin/UserManagement';
|
import { UserManagement } from '@/components/admin/UserManagement';
|
||||||
import { AdminHeader } from '@/components/layout/AdminHeader';
|
import { AdminHeader } from '@/components/layout/AdminHeader';
|
||||||
import { useModerationStats } from '@/hooks/useModerationStats';
|
import { useModerationStats } from '@/hooks/useModerationStats';
|
||||||
@@ -20,6 +20,7 @@ export default function Admin() {
|
|||||||
const { isModerator, loading: roleLoading } = useUserRole();
|
const { isModerator, loading: roleLoading } = useUserRole();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
||||||
|
const reportsQueueRef = useRef<ReportsQueueRef>(null);
|
||||||
|
|
||||||
// Get admin settings for polling configuration
|
// Get admin settings for polling configuration
|
||||||
const {
|
const {
|
||||||
@@ -40,6 +41,7 @@ export default function Admin() {
|
|||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
moderationQueueRef.current?.refresh();
|
moderationQueueRef.current?.refresh();
|
||||||
|
reportsQueueRef.current?.refresh();
|
||||||
refreshStats();
|
refreshStats();
|
||||||
}, [refreshStats]);
|
}, [refreshStats]);
|
||||||
|
|
||||||
@@ -162,7 +164,7 @@ export default function Admin() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
<TabsContent value="reports">
|
||||||
<ReportsQueue />
|
<ReportsQueue ref={reportsQueueRef} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user