Refactor moderation queues

This commit is contained in:
gpt-engineer-app[bot]
2025-10-03 18:40:34 +00:00
parent e6238c45b3
commit a2d3ed5ea4
8 changed files with 62 additions and 629 deletions

View File

@@ -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]'}`}>

View File

@@ -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>
);
}

View File

@@ -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>
); );
} });

View File

@@ -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();

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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>