From c62935818a47bfe0fc45c1b3fe7a814bfaf1bd48 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:20:50 +0000 Subject: [PATCH] Refactor: Implement smooth moderation queue --- src/components/moderation/ModerationQueue.tsx | 215 ++++++++++++------ src/components/moderation/QueueItem.tsx | 9 +- .../moderation/QueueItemSkeleton.tsx | 11 +- src/components/moderation/QueueSkeleton.tsx | 2 +- src/index.css | 39 ++++ 5 files changed, 201 insertions(+), 75 deletions(-) diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 181997f9..d8717664 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -61,6 +61,7 @@ interface ModerationItem { escalated?: boolean; assigned_to?: string; locked_until?: string; + _removing?: boolean; } type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; @@ -81,8 +82,7 @@ export interface ModerationQueueRef { export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [isInitialLoad, setIsInitialLoad] = useState(true); + const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'refreshing' | 'ready'>('initial'); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); const [activeTab, setActiveTab] = useState('mainQueue'); @@ -100,7 +100,6 @@ export const ModerationQueue = forwardRef((props, ref) => { const [selectedItemForAction, setSelectedItemForAction] = useState(null); const [interactingWith, setInteractingWith] = useState>(new Set()); const [newItemsCount, setNewItemsCount] = useState(0); - const [isRefreshing, setIsRefreshing] = useState(false); const [profileCache, setProfileCache] = useState>(new Map()); const [entityCache, setEntityCache] = useState<{ rides: Map, @@ -192,7 +191,7 @@ export const ModerationQueue = forwardRef((props, ref) => { // Enable transitions after initial render useEffect(() => { - if (!loading && items.length > 0 && !hasRenderedOnce) { + if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) { // Use requestAnimationFrame to enable transitions AFTER first paint requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -200,7 +199,7 @@ export const ModerationQueue = forwardRef((props, ref) => { }); }); } - }, [loading, items.length, hasRenderedOnce]); + }, [loadingState, items.length, hasRenderedOnce]); const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false, tab: QueueTab = 'mainQueue') => { if (!userRef.current) { @@ -236,9 +235,9 @@ export const ModerationQueue = forwardRef((props, ref) => { try { // Set loading states if (!silent) { - setLoading(true); + setLoadingState('loading'); } else { - setIsRefreshing(true); + setLoadingState('refreshing'); } // Build base query for content submissions @@ -681,15 +680,13 @@ export const ModerationQueue = forwardRef((props, ref) => { }); } finally { fetchInProgressRef.current = false; - setLoading(false); - setIsRefreshing(false); - setIsInitialLoad(false); + setLoadingState('ready'); } }, []); // Empty deps - use refs instead // Debounced filters to prevent rapid-fire calls - const debouncedEntityFilter = useDebounce(activeEntityFilter, 1000); - const debouncedStatusFilter = useDebounce(activeStatusFilter, 1000); + const debouncedEntityFilter = useDebounce(activeEntityFilter, 300); + const debouncedStatusFilter = useDebounce(activeStatusFilter, 300); // Store latest filter values in ref to avoid dependency issues const filtersRef = useRef({ entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }); @@ -760,7 +757,7 @@ export const ModerationQueue = forwardRef((props, ref) => { // Polling for auto-refresh (only if realtime is disabled) useEffect(() => { // STRICT CHECK: Only enable polling if explicitly disabled - if (!user || refreshMode !== 'auto' || isInitialLoad || useRealtimeQueue) { + if (!user || refreshMode !== 'auto' || loadingState === 'initial' || useRealtimeQueue) { if (useRealtimeQueue && refreshMode === 'auto') { console.log('✅ Polling DISABLED - using realtime subscriptions'); } @@ -778,7 +775,7 @@ export const ModerationQueue = forwardRef((props, ref) => { console.log('🛑 Polling stopped'); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]); + }, [user, refreshMode, pollInterval, loadingState, useRealtimeQueue]); // Real-time subscription for NEW submissions (replaces polling) useEffect(() => { @@ -1199,22 +1196,28 @@ export const ModerationQueue = forwardRef((props, ref) => { const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') && (action === 'approved' || action === 'rejected'); - // Optimistic UI update - batch with requestAnimationFrame for smoother rendering - requestAnimationFrame(() => { - if (shouldRemove) { + // Optimistic UI update with smooth exit animation + if (shouldRemove) { + // Step 1: Mark item as "removing" for exit animation + setItems(prev => prev.map(i => + i.id === item.id ? { ...i, _removing: true } : i + )); + + // Step 2: Wait for exit animation (300ms), then remove + setTimeout(() => { setItems(prev => prev.filter(i => i.id !== item.id)); // Mark as recently removed - ignore realtime updates for 10 seconds recentlyRemovedRef.current.add(item.id); setTimeout(() => { recentlyRemovedRef.current.delete(item.id); - }, 10000); // Increased from 3000 - } else { - setItems(prev => prev.map(i => - i.id === item.id ? { ...i, status: action } : i - )); - } - }); + }, 10000); + }, 300); + } 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) { @@ -1836,9 +1839,13 @@ export const ModerationQueue = forwardRef((props, ref) => { }, []); const QueueContent = () => { - // Show skeleton during initial load OR during mounting phase - if ((isInitialLoad && loading) || (isMountingRef.current && !initialFetchCompleteRef.current)) { - return ; + // Show skeleton during ANY loading state (except refreshing) + if (loadingState === 'initial' || loadingState === 'loading') { + return ( +
+ +
+ ); } if (items.length === 0) { @@ -1859,35 +1866,47 @@ export const ModerationQueue = forwardRef((props, ref) => { }, [items, sortConfig]); return ( -
- {sortedItems.map((item) => ( - + {sortedItems.map((item, index) => ( +
queue.claimSubmission(id)} - onDeleteSubmission={handleDeleteSubmission} - onInteractionFocus={handleInteractionFocus} - onInteractionBlur={handleInteractionBlur} - /> + className="animate-in fade-in-0 slide-in-from-bottom-2" + style={{ + animationDelay: hasRenderedOnce ? `${index * 30}ms` : '0ms', + animationDuration: '250ms', + animationFillMode: 'backwards' + }} + > + queue.claimSubmission(id)} + onDeleteSubmission={handleDeleteSubmission} + onInteractionFocus={handleInteractionFocus} + onInteractionBlur={handleInteractionBlur} + /> +
))}
); @@ -2010,7 +2029,13 @@ export const ModerationQueue = forwardRef((props, ref) => {
- { + setActiveEntityFilter(value as EntityFilter); + setLoadingState('loading'); + }} + >
@@ -2050,7 +2075,13 @@ export const ModerationQueue = forwardRef((props, ref) => {
- { + setActiveStatusFilter(value as StatusFilter); + setLoadingState('loading'); + }} + > {activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter} @@ -2174,12 +2205,22 @@ export const ModerationQueue = forwardRef((props, ref) => { variant="default" size="sm" onClick={() => { - // Merge pending new items into the main queue at the top + // Smooth merge with loading state if (pendingNewItems.length > 0) { - setItems(prev => [...pendingNewItems, ...prev]); - setPendingNewItems([]); + setLoadingState('loading'); + + // After 150ms, merge items + setTimeout(() => { + setItems(prev => [...pendingNewItems, ...prev]); + setPendingNewItems([]); + setNewItemsCount(0); + + // Show content again after brief pause + setTimeout(() => { + setLoadingState('ready'); + }, 100); + }, 150); } - setNewItemsCount(0); console.log('✅ New items merged into queue'); }} className="ml-4" @@ -2196,7 +2237,7 @@ export const ModerationQueue = forwardRef((props, ref) => { {/* Pagination Controls */} - {totalPages > 1 && !loading && ( + {totalPages > 1 && loadingState === 'ready' && (
@@ -2208,8 +2249,10 @@ export const ModerationQueue = forwardRef((props, ref) => {