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

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 { 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(() => {
fetchReports();
}, []);
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>
);
}
});

View File

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