Refactor: Implement smooth moderation queue

This commit is contained in:
gpt-engineer-app[bot]
2025-10-10 19:20:50 +00:00
parent cd8f770807
commit c62935818a
5 changed files with 201 additions and 75 deletions

View File

@@ -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<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile();
const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'refreshing' | 'ready'>('initial');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<QueueTab>('mainQueue');
@@ -100,7 +100,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
const [newItemsCount, setNewItemsCount] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [profileCache, setProfileCache] = useState<Map<string, any>>(new Map());
const [entityCache, setEntityCache] = useState<{
rides: Map<string, any>,
@@ -192,7 +191,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
}, []);
const QueueContent = () => {
// Show skeleton during initial load OR during mounting phase
if ((isInitialLoad && loading) || (isMountingRef.current && !initialFetchCompleteRef.current)) {
return <QueueSkeleton count={5} />;
// Show skeleton during ANY loading state (except refreshing)
if (loadingState === 'initial' || loadingState === 'loading') {
return (
<div className="animate-in fade-in-50 duration-200">
<QueueSkeleton count={Math.min(pageSize, 10)} />
</div>
);
}
if (items.length === 0) {
@@ -1859,35 +1866,47 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}, [items, sortConfig]);
return (
<div
className="flex flex-col gap-6"
data-initial-load={!hasRenderedOnce ? "true" : "false"}
style={{ willChange: 'transform' }}
>
{sortedItems.map((item) => (
<QueueItem
<div
className="flex flex-col gap-6 transition-opacity duration-300 ease-in-out queue-fade-enter"
data-initial-load={!hasRenderedOnce ? "true" : "false"}
style={{
willChange: 'transform, opacity'
}}
>
{sortedItems.map((item, index) => (
<div
key={item.id}
item={item}
isMobile={isMobile}
actionLoading={actionLoading}
lockedSubmissions={lockedSubmissions}
currentLockSubmissionId={queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queue.isLoading}
isInitialRender={!hasRenderedOnce}
onNoteChange={handleNoteChange}
onApprove={handleModerationAction}
onResetToPending={handleResetToPending}
onRetryFailed={handleRetryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onClaimSubmission={(id) => 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'
}}
>
<QueueItem
key={item.id}
item={item}
isMobile={isMobile}
actionLoading={actionLoading}
lockedSubmissions={lockedSubmissions}
currentLockSubmissionId={queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queue.isLoading}
isInitialRender={!hasRenderedOnce}
onNoteChange={handleNoteChange}
onApprove={handleModerationAction}
onResetToPending={handleResetToPending}
onRetryFailed={handleRetryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onClaimSubmission={(id) => queue.claimSubmission(id)}
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={handleInteractionFocus}
onInteractionBlur={handleInteractionBlur}
/>
</div>
))}
</div>
);
@@ -2010,7 +2029,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select value={activeEntityFilter} onValueChange={(value) => setActiveEntityFilter(value as EntityFilter)}>
<Select
value={activeEntityFilter}
onValueChange={(value) => {
setActiveEntityFilter(value as EntityFilter);
setLoadingState('loading');
}}
>
<SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectValue>
<div className="flex items-center gap-2">
@@ -2050,7 +2075,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select value={activeStatusFilter} onValueChange={(value) => setActiveStatusFilter(value as StatusFilter)}>
<Select
value={activeStatusFilter}
onValueChange={(value) => {
setActiveStatusFilter(value as StatusFilter);
setLoadingState('loading');
}}
>
<SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
@@ -2174,12 +2205,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
<QueueContent />
{/* Pagination Controls */}
{totalPages > 1 && !loading && (
{totalPages > 1 && loadingState === 'ready' && (
<div className="flex items-center justify-between border-t pt-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
@@ -2208,8 +2249,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setLoadingState('loading');
setPageSize(parseInt(value));
setCurrentPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<SelectTrigger className="w-[120px] h-8">
@@ -2231,7 +2274,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => {
setLoadingState('loading');
setCurrentPage(p => Math.max(1, p - 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
disabled={currentPage === 1}
>
Previous
@@ -2242,7 +2289,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
onClick={() => {
setLoadingState('loading');
setCurrentPage(p => Math.min(totalPages, p + 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
disabled={currentPage === totalPages}
>
Next
@@ -2253,7 +2304,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => {
setLoadingState('loading');
setCurrentPage(p => Math.max(1, p - 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
@@ -2261,7 +2316,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{currentPage > 3 && (
<>
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(1)} isActive={currentPage === 1}>
<PaginationLink
onClick={() => {
setLoadingState('loading');
setCurrentPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === 1}
>
1
</PaginationLink>
</PaginationItem>
@@ -2274,7 +2336,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.map(page => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
onClick={() => {
setLoadingState('loading');
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === page}
>
{page}
@@ -2287,7 +2353,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink onClick={() => setCurrentPage(totalPages)} isActive={currentPage === totalPages}>
<PaginationLink
onClick={() => {
setLoadingState('loading');
setCurrentPage(totalPages);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === totalPages}
>
{totalPages}
</PaginationLink>
</PaginationItem>
@@ -2296,7 +2369,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
onClick={() => {
setLoadingState('loading');
setCurrentPage(p => Math.min(totalPages, p + 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>