mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 05:31:14 -05:00
Refactor: Implement debouncing and anti-flashing fixes
This commit is contained in:
@@ -121,6 +121,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const preserveInteraction = getPreserveInteractionState();
|
const preserveInteraction = getPreserveInteractionState();
|
||||||
const useRealtimeQueue = getUseRealtimeQueue();
|
const useRealtimeQueue = getUseRealtimeQueue();
|
||||||
|
|
||||||
|
// Track recently removed items to prevent realtime override of optimistic updates
|
||||||
|
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||||
|
const prevLocksRef = useRef<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
// Store admin settings and stable refs to avoid triggering fetchItems recreation
|
// Store admin settings and stable refs to avoid triggering fetchItems recreation
|
||||||
const refreshStrategyRef = useRef(refreshStrategy);
|
const refreshStrategyRef = useRef(refreshStrategy);
|
||||||
const preserveInteractionRef = useRef(preserveInteraction);
|
const preserveInteractionRef = useRef(preserveInteraction);
|
||||||
@@ -574,7 +578,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]);
|
}, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]);
|
||||||
|
|
||||||
// Real-time subscription for lock status
|
// Real-time subscription for lock status (optimized to prevent unnecessary updates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
@@ -589,24 +593,21 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
const newData = payload.new as any;
|
const newData = payload.new as any;
|
||||||
|
const isLocked = newData.assigned_to && newData.assigned_to !== user.id &&
|
||||||
|
newData.locked_until && new Date(newData.locked_until) > new Date();
|
||||||
|
const wasLocked = prevLocksRef.current.get(newData.id) || false;
|
||||||
|
|
||||||
|
// Only update if lock state actually changed
|
||||||
|
if (isLocked !== wasLocked) {
|
||||||
|
prevLocksRef.current.set(newData.id, isLocked);
|
||||||
|
|
||||||
// Track submissions locked by others
|
|
||||||
if (newData.assigned_to && newData.assigned_to !== user.id && newData.locked_until) {
|
|
||||||
const lockExpiry = new Date(newData.locked_until);
|
|
||||||
if (lockExpiry > new Date()) {
|
|
||||||
setLockedSubmissions((prev) => new Set(prev).add(newData.id));
|
|
||||||
} else {
|
|
||||||
setLockedSubmissions((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(newData.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Lock released
|
|
||||||
setLockedSubmissions((prev) => {
|
setLockedSubmissions((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(newData.id);
|
if (isLocked) {
|
||||||
|
next.add(newData.id);
|
||||||
|
} else {
|
||||||
|
next.delete(newData.id);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -635,6 +636,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
async (payload) => {
|
async (payload) => {
|
||||||
const newSubmission = payload.new as any;
|
const newSubmission = payload.new as any;
|
||||||
|
|
||||||
|
// Ignore if recently removed (optimistic update)
|
||||||
|
if (recentlyRemovedRef.current.has(newSubmission.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only process pending/partially_approved submissions
|
// Only process pending/partially_approved submissions
|
||||||
if (!['pending', 'partially_approved'].includes(newSubmission.status)) {
|
if (!['pending', 'partially_approved'].includes(newSubmission.status)) {
|
||||||
return;
|
return;
|
||||||
@@ -857,14 +863,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') &&
|
const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') &&
|
||||||
(action === 'approved' || action === 'rejected');
|
(action === 'approved' || action === 'rejected');
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic UI update - batch with requestAnimationFrame for smoother rendering
|
||||||
if (shouldRemove) {
|
requestAnimationFrame(() => {
|
||||||
setItems(prev => prev.filter(i => i.id !== item.id));
|
if (shouldRemove) {
|
||||||
} else {
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
setItems(prev => prev.map(i =>
|
|
||||||
i.id === item.id ? { ...i, status: action } : i
|
// Mark as recently removed - ignore realtime updates for 3 seconds
|
||||||
));
|
recentlyRemovedRef.current.add(item.id);
|
||||||
}
|
setTimeout(() => {
|
||||||
|
recentlyRemovedRef.current.delete(item.id);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
setItems(prev => prev.map(i =>
|
||||||
|
i.id === item.id ? { ...i, status: action } : i
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Release lock if this submission is claimed by current user
|
// Release lock if this submission is claimed by current user
|
||||||
if (queue.currentLock?.submissionId === item.id) {
|
if (queue.currentLock?.submissionId === item.id) {
|
||||||
@@ -1431,13 +1445,20 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.id} className={`border-l-4 ${
|
<Card
|
||||||
item.status === 'flagged' ? 'border-l-red-500' :
|
key={item.id}
|
||||||
item.status === 'approved' ? 'border-l-green-500' :
|
className={`border-l-4 transition-opacity duration-200 ${
|
||||||
|
item.status === 'flagged' ? 'border-l-red-500' :
|
||||||
|
item.status === 'approved' ? 'border-l-green-500' :
|
||||||
item.status === 'rejected' ? 'border-l-red-400' :
|
item.status === 'rejected' ? 'border-l-red-400' :
|
||||||
item.status === 'partially_approved' ? 'border-l-yellow-500' :
|
item.status === 'partially_approved' ? 'border-l-yellow-500' :
|
||||||
'border-l-amber-500'
|
'border-l-amber-500'
|
||||||
}`}>
|
}`}
|
||||||
|
style={{
|
||||||
|
opacity: actionLoading === item.id ? 0.5 : 1,
|
||||||
|
pointerEvents: actionLoading === item.id ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>
|
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>
|
||||||
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
|
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
|||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
const onStatsChangeRef = useRef(onStatsChange);
|
const onStatsChangeRef = useRef(onStatsChange);
|
||||||
|
const statsDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Update ref when callback changes
|
// Update ref when callback changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,27 +94,35 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
|||||||
}
|
}
|
||||||
}, [enabled, fetchStats]);
|
}, [enabled, fetchStats]);
|
||||||
|
|
||||||
|
// Debounced stats fetch to prevent rapid-fire updates
|
||||||
|
const debouncedFetchStats = useCallback(() => {
|
||||||
|
if (statsDebounceRef.current) {
|
||||||
|
clearTimeout(statsDebounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
statsDebounceRef.current = setTimeout(() => {
|
||||||
|
fetchStats(true); // Silent refresh
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
// Realtime subscription for instant stat updates
|
// Realtime subscription for instant stat updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !realtimeEnabled) return;
|
if (!enabled || !realtimeEnabled) return;
|
||||||
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
.channel('moderation-stats-realtime')
|
.channel('moderation-stats-realtime')
|
||||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, () => {
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, debouncedFetchStats)
|
||||||
fetchStats(true); // Silent refresh
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, debouncedFetchStats)
|
||||||
})
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, debouncedFetchStats)
|
||||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, () => {
|
|
||||||
fetchStats(true);
|
|
||||||
})
|
|
||||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, () => {
|
|
||||||
fetchStats(true);
|
|
||||||
})
|
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel);
|
supabase.removeChannel(channel);
|
||||||
|
if (statsDebounceRef.current) {
|
||||||
|
clearTimeout(statsDebounceRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [enabled, realtimeEnabled, fetchStats]);
|
}, [enabled, realtimeEnabled, debouncedFetchStats]);
|
||||||
|
|
||||||
// Polling (fallback when realtime is disabled)
|
// Polling (fallback when realtime is disabled)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user