mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -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 { 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';
|
||||||
|
|
||||||
interface ModerationItem {
|
interface ModerationItem {
|
||||||
id: string;
|
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(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
fetchItems(activeEntityFilter, activeStatusFilter);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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,
|
||||||
@@ -59,6 +60,20 @@ 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();
|
||||||
|
|||||||
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 { UserManagement } from '@/components/admin/UserManagement';
|
||||||
import { AdminHeader } from '@/components/layout/AdminHeader';
|
import { AdminHeader } from '@/components/layout/AdminHeader';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useRealtimeModerationStats } from '@/hooks/useRealtimeModerationStats';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
@@ -18,76 +19,19 @@ export default function Admin() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
const moderationQueueRef = useRef<ModerationQueueRef>(null);
|
||||||
|
|
||||||
// State for dashboard stats
|
// Use realtime stats hook for live updates
|
||||||
const [stats, setStats] = useState({
|
const { stats: realtimeStats, refresh: refreshStats } = useRealtimeModerationStats({
|
||||||
pendingSubmissions: 0,
|
onStatsChange: (newStats) => {
|
||||||
openReports: 0,
|
console.log('Stats updated in real-time:', newStats);
|
||||||
flaggedContent: 0,
|
},
|
||||||
loading: true,
|
enabled: !!user && !authLoading && !roleLoading && isModerator(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
if (!user || isFetching) {
|
refreshStats();
|
||||||
console.log('Skipping stats fetch - user not authenticated or already fetching');
|
}, [refreshStats]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
moderationQueueRef.current?.refresh();
|
moderationQueueRef.current?.refresh();
|
||||||
@@ -105,11 +49,8 @@ export default function Admin() {
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stats when user is authenticated and authorized
|
|
||||||
fetchStats();
|
|
||||||
}
|
}
|
||||||
}, [user, authLoading, roleLoading, navigate]);
|
}, [user, authLoading, roleLoading, navigate, isModerator]);
|
||||||
|
|
||||||
if (authLoading || roleLoading) {
|
if (authLoading || roleLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -146,11 +87,7 @@ export default function Admin() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats.loading ? (
|
{realtimeStats.pendingSubmissions}
|
||||||
<span className="animate-pulse">--</span>
|
|
||||||
) : (
|
|
||||||
stats.pendingSubmissions
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Content submissions awaiting moderation
|
Content submissions awaiting moderation
|
||||||
@@ -165,11 +102,7 @@ export default function Admin() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats.loading ? (
|
{realtimeStats.openReports}
|
||||||
<span className="animate-pulse">--</span>
|
|
||||||
) : (
|
|
||||||
stats.openReports
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
User reports to review
|
User reports to review
|
||||||
@@ -184,11 +117,7 @@ export default function Admin() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats.loading ? (
|
{realtimeStats.flaggedContent}
|
||||||
<span className="animate-pulse">--</span>
|
|
||||||
) : (
|
|
||||||
stats.flaggedContent
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Auto-flagged items
|
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