mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 05:51:12 -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 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
|
||||
const refreshStrategyRef = useRef(refreshStrategy);
|
||||
const preserveInteractionRef = useRef(preserveInteraction);
|
||||
@@ -574,7 +578,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]);
|
||||
|
||||
// Real-time subscription for lock status
|
||||
// Real-time subscription for lock status (optimized to prevent unnecessary updates)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -589,24 +593,21 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
},
|
||||
(payload) => {
|
||||
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) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(newData.id);
|
||||
if (isLocked) {
|
||||
next.add(newData.id);
|
||||
} else {
|
||||
next.delete(newData.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -635,6 +636,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
async (payload) => {
|
||||
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
|
||||
if (!['pending', 'partially_approved'].includes(newSubmission.status)) {
|
||||
return;
|
||||
@@ -857,14 +863,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') &&
|
||||
(action === 'approved' || action === 'rejected');
|
||||
|
||||
// Optimistic update
|
||||
if (shouldRemove) {
|
||||
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||
} else {
|
||||
setItems(prev => prev.map(i =>
|
||||
i.id === item.id ? { ...i, status: action } : i
|
||||
));
|
||||
}
|
||||
// Optimistic UI update - batch with requestAnimationFrame for smoother rendering
|
||||
requestAnimationFrame(() => {
|
||||
if (shouldRemove) {
|
||||
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||
|
||||
// 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
|
||||
if (queue.currentLock?.submissionId === item.id) {
|
||||
@@ -1431,13 +1445,20 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className={`border-l-4 ${
|
||||
item.status === 'flagged' ? 'border-l-red-500' :
|
||||
item.status === 'approved' ? 'border-l-green-500' :
|
||||
<Card
|
||||
key={item.id}
|
||||
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 === 'partially_approved' ? 'border-l-yellow-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"}>
|
||||
<div className={`flex gap-3 ${isMobile ? 'flex-col' : 'items-center justify-between'}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -34,6 +34,7 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const onStatsChangeRef = useRef(onStatsChange);
|
||||
const statsDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update ref when callback changes
|
||||
useEffect(() => {
|
||||
@@ -93,27 +94,35 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!enabled || !realtimeEnabled) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel('moderation-stats-realtime')
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, () => {
|
||||
fetchStats(true); // Silent refresh
|
||||
})
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, () => {
|
||||
fetchStats(true);
|
||||
})
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, () => {
|
||||
fetchStats(true);
|
||||
})
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, debouncedFetchStats)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, debouncedFetchStats)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, debouncedFetchStats)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
if (statsDebounceRef.current) {
|
||||
clearTimeout(statsDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, realtimeEnabled, fetchStats]);
|
||||
}, [enabled, realtimeEnabled, debouncedFetchStats]);
|
||||
|
||||
// Polling (fallback when realtime is disabled)
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user