mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Implement real-time features
This commit is contained in:
@@ -14,6 +14,7 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { format } from 'date-fns';
|
||||
import { PhotoModal } from './PhotoModal';
|
||||
import { SubmissionReviewManager } from './SubmissionReviewManager';
|
||||
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
|
||||
|
||||
interface ModerationItem {
|
||||
id: string;
|
||||
@@ -339,6 +340,36 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Set up realtime subscriptions
|
||||
useRealtimeSubmissions({
|
||||
onInsert: (payload) => {
|
||||
console.log('New submission received');
|
||||
toast({
|
||||
title: 'New Submission',
|
||||
description: 'A new content submission has been added',
|
||||
});
|
||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||
},
|
||||
onUpdate: (payload) => {
|
||||
console.log('Submission updated');
|
||||
// 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) => {
|
||||
console.log('Submission deleted');
|
||||
setItems(prevItems =>
|
||||
prevItems.filter(item => !(item.id === payload.old.id && item.type === 'content_submission'))
|
||||
);
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -59,6 +60,20 @@ 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();
|
||||
|
||||
126
src/hooks/useRealtimeModerationStats.ts
Normal file
126
src/hooks/useRealtimeModerationStats.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
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 [stats, setStats] = useState<ModerationStats>({
|
||||
pendingSubmissions: 0,
|
||||
openReports: 0,
|
||||
flaggedContent: 0,
|
||||
});
|
||||
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
|
||||
const [updateTimer, setUpdateTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchStats = 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);
|
||||
onStatsChange?.(newStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching moderation stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedFetchStats = () => {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
const timer = setTimeout(fetchStats, debounceMs);
|
||||
setUpdateTimer(timer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
|
||||
// Set up realtime subscriptions
|
||||
const realtimeChannel = supabase
|
||||
.channel('moderation-stats-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'content_submissions',
|
||||
},
|
||||
() => {
|
||||
console.log('Content submissions changed');
|
||||
debouncedFetchStats();
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'reports',
|
||||
},
|
||||
() => {
|
||||
console.log('Reports changed');
|
||||
debouncedFetchStats();
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'reviews',
|
||||
},
|
||||
() => {
|
||||
console.log('Reviews changed');
|
||||
debouncedFetchStats();
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
console.log('Moderation stats realtime status:', status);
|
||||
});
|
||||
|
||||
setChannel(realtimeChannel);
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up moderation stats realtime subscription');
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { stats, refresh: fetchStats };
|
||||
};
|
||||
46
src/hooks/useRealtimeSubmissionItems.ts
Normal file
46
src/hooks/useRealtimeSubmissionItems.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !submissionId) return;
|
||||
|
||||
const realtimeChannel = supabase
|
||||
.channel(`submission-items-${submissionId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'submission_items',
|
||||
filter: `submission_id=eq.${submissionId}`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Submission item updated:', payload);
|
||||
onUpdate?.(payload);
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
console.log('Submission items realtime status:', status);
|
||||
});
|
||||
|
||||
setChannel(realtimeChannel);
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up submission items realtime subscription');
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
};
|
||||
}, [submissionId, enabled, onUpdate]);
|
||||
|
||||
return { channel };
|
||||
};
|
||||
70
src/hooks/useRealtimeSubmissions.ts
Normal file
70
src/hooks/useRealtimeSubmissions.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
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 [channel, setChannel] = useState<RealtimeChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const realtimeChannel = supabase
|
||||
.channel('content-submissions-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'content_submissions',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Submission inserted:', payload);
|
||||
onInsert?.(payload);
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'content_submissions',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Submission updated:', payload);
|
||||
onUpdate?.(payload);
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'content_submissions',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Submission deleted:', payload);
|
||||
onDelete?.(payload);
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
console.log('Submissions realtime status:', status);
|
||||
});
|
||||
|
||||
setChannel(realtimeChannel);
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up submissions realtime subscription');
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
};
|
||||
}, [enabled, onInsert, onUpdate, onDelete]);
|
||||
|
||||
return { channel };
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { ReportsQueue } from '@/components/moderation/ReportsQueue';
|
||||
import { UserManagement } from '@/components/admin/UserManagement';
|
||||
import { AdminHeader } from '@/components/layout/AdminHeader';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useRealtimeModerationStats } from '@/hooks/useRealtimeModerationStats';
|
||||
|
||||
export default function Admin() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -18,76 +19,19 @@ export default function Admin() {
|
||||
const navigate = useNavigate();
|
||||
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
||||
|
||||
// State for dashboard stats
|
||||
const [stats, setStats] = useState({
|
||||
pendingSubmissions: 0,
|
||||
openReports: 0,
|
||||
flaggedContent: 0,
|
||||
loading: true,
|
||||
// Use realtime stats hook for live updates
|
||||
const { stats: realtimeStats, refresh: refreshStats } = useRealtimeModerationStats({
|
||||
onStatsChange: (newStats) => {
|
||||
console.log('Stats updated in real-time:', newStats);
|
||||
},
|
||||
enabled: !!user && !authLoading && !roleLoading && isModerator(),
|
||||
});
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!user || isFetching) {
|
||||
console.log('Skipping stats fetch - user not authenticated or already fetching');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetching(true);
|
||||
try {
|
||||
setStats(prev => ({ ...prev, loading: true }));
|
||||
|
||||
// Fetch pending submissions count
|
||||
const { count: pendingCount, error: submissionsError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (submissionsError) {
|
||||
console.error('Error fetching pending submissions:', submissionsError);
|
||||
throw submissionsError;
|
||||
}
|
||||
|
||||
// Fetch open reports count
|
||||
const { count: reportsCount, error: reportsError } = await supabase
|
||||
.from('reports')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (reportsError) {
|
||||
console.error('Error fetching reports:', reportsError);
|
||||
throw reportsError;
|
||||
}
|
||||
|
||||
// Fetch flagged content count (reviews)
|
||||
const { count: flaggedCount, error: flaggedError } = await supabase
|
||||
.from('reviews')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('moderation_status', 'flagged');
|
||||
|
||||
if (flaggedError) {
|
||||
console.error('Error fetching flagged content:', flaggedError);
|
||||
throw flaggedError;
|
||||
}
|
||||
|
||||
setStats({
|
||||
pendingSubmissions: pendingCount || 0,
|
||||
openReports: reportsCount || 0,
|
||||
flaggedContent: flaggedCount || 0,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching admin stats:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
details: error.details
|
||||
});
|
||||
setStats(prev => ({ ...prev, loading: false }));
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, []);
|
||||
refreshStats();
|
||||
}, [refreshStats]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
moderationQueueRef.current?.refresh();
|
||||
@@ -105,11 +49,8 @@ export default function Admin() {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch stats when user is authenticated and authorized
|
||||
fetchStats();
|
||||
}
|
||||
}, [user, authLoading, roleLoading, navigate]);
|
||||
}, [user, authLoading, roleLoading, navigate, isModerator]);
|
||||
|
||||
if (authLoading || roleLoading) {
|
||||
return (
|
||||
@@ -146,11 +87,7 @@ export default function Admin() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.loading ? (
|
||||
<span className="animate-pulse">--</span>
|
||||
) : (
|
||||
stats.pendingSubmissions
|
||||
)}
|
||||
{realtimeStats.pendingSubmissions}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Content submissions awaiting moderation
|
||||
@@ -165,11 +102,7 @@ export default function Admin() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.loading ? (
|
||||
<span className="animate-pulse">--</span>
|
||||
) : (
|
||||
stats.openReports
|
||||
)}
|
||||
{realtimeStats.openReports}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
User reports to review
|
||||
@@ -184,11 +117,7 @@ export default function Admin() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.loading ? (
|
||||
<span className="animate-pulse">--</span>
|
||||
) : (
|
||||
stats.flaggedContent
|
||||
)}
|
||||
{realtimeStats.flaggedContent}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-flagged items
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Enable replica identity for complete row data during realtime updates
|
||||
ALTER TABLE public.content_submissions REPLICA IDENTITY FULL;
|
||||
ALTER TABLE public.submission_items REPLICA IDENTITY FULL;
|
||||
ALTER TABLE public.reviews REPLICA IDENTITY FULL;
|
||||
ALTER TABLE public.reports REPLICA IDENTITY FULL;
|
||||
|
||||
-- Add tables to realtime publication for live updates
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.content_submissions;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.submission_items;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.reviews;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.reports;
|
||||
Reference in New Issue
Block a user