mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 16: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 { PhotoModal } from './PhotoModal';
|
||||
import { SubmissionReviewManager } from './SubmissionReviewManager';
|
||||
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
||||
import { SubmissionItemsList } from './SubmissionItemsList';
|
||||
import { RealtimeConnectionStatus } from './RealtimeConnectionStatus';
|
||||
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
|
||||
interface ModerationItem {
|
||||
id: string;
|
||||
@@ -70,6 +69,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const { isAdmin, isSuperuser } = useUserRole();
|
||||
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: () => {
|
||||
@@ -346,116 +350,26 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Set up realtime subscriptions
|
||||
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,
|
||||
});
|
||||
|
||||
// Initial fetch on mount and filter changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||
}
|
||||
}, [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) => {
|
||||
setActionLoading(item.id);
|
||||
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 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>
|
||||
<RealtimeConnectionStatus
|
||||
connectionState={submissionsConnectionState}
|
||||
onReconnect={reconnectSubmissions}
|
||||
/>
|
||||
</div>
|
||||
<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]'}`}>
|
||||
|
||||
@@ -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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/label';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { format } from 'date-fns';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
@@ -38,11 +40,26 @@ const STATUS_COLORS = {
|
||||
dismissed: 'outline',
|
||||
} as const;
|
||||
|
||||
export function ReportsQueue() {
|
||||
export interface ReportsQueueRef {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
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 () => {
|
||||
try {
|
||||
@@ -110,9 +127,25 @@ export function ReportsQueue() {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch on mount
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
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') => {
|
||||
setActionLoading(reportId);
|
||||
@@ -258,4 +291,4 @@ export function ReportsQueue() {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
|
||||
import {
|
||||
fetchSubmissionItems,
|
||||
buildDependencyTree,
|
||||
@@ -60,20 +59,6 @@ export function SubmissionReviewManager({
|
||||
const isMobile = useIsMobile();
|
||||
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(() => {
|
||||
if (open && submissionId) {
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 { AdminHeader } from '@/components/layout/AdminHeader';
|
||||
import { useModerationStats } from '@/hooks/useModerationStats';
|
||||
@@ -20,6 +20,7 @@ export default function Admin() {
|
||||
const { isModerator, loading: roleLoading } = useUserRole();
|
||||
const navigate = useNavigate();
|
||||
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
||||
const reportsQueueRef = useRef<ReportsQueueRef>(null);
|
||||
|
||||
// Get admin settings for polling configuration
|
||||
const {
|
||||
@@ -40,6 +41,7 @@ export default function Admin() {
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
moderationQueueRef.current?.refresh();
|
||||
reportsQueueRef.current?.refresh();
|
||||
refreshStats();
|
||||
}, [refreshStats]);
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function Admin() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reports">
|
||||
<ReportsQueue />
|
||||
<ReportsQueue ref={reportsQueueRef} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user