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>

View File

@@ -38,6 +38,7 @@ interface ModerationItem {
escalated?: boolean;
assigned_to?: string;
locked_until?: string;
_removing?: boolean;
}
import { ValidationSummary } from './ValidationSummary';
@@ -108,7 +109,9 @@ export const QueueItem = memo(({
return (
<Card
key={item.id}
className={`border-l-4 ${
className={`border-l-4 transition-all duration-300 ${
item._removing ? 'opacity-0 scale-95 pointer-events-none' : ''
} ${
validationResult?.blockingErrors && validationResult.blockingErrors.length > 0 ? 'border-l-red-600' :
item.status === 'flagged' ? 'border-l-red-500' :
item.status === 'approved' ? 'border-l-green-500' :
@@ -117,9 +120,9 @@ export const QueueItem = memo(({
'border-l-amber-500'
}`}
style={{
opacity: actionLoading === item.id ? 0.5 : 1,
opacity: actionLoading === item.id ? 0.5 : (item._removing ? 0 : 1),
pointerEvents: actionLoading === item.id ? 'none' : 'auto',
transition: isInitialRender ? 'none' : 'opacity 200ms'
transition: isInitialRender ? 'none' : 'all 300ms ease-in-out'
}}
>
<CardHeader className={isMobile ? "pb-3 p-4" : "pb-4"}>

View File

@@ -1,9 +1,16 @@
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function QueueItemSkeleton() {
export function QueueItemSkeleton({ index = 0 }: { index?: number }) {
return (
<Card className="border-l-4 border-l-muted">
<Card
className="border-l-4 border-l-muted animate-in fade-in-0 slide-in-from-bottom-4"
style={{
animationDelay: `${index * 50}ms`,
animationDuration: '300ms',
animationFillMode: 'backwards'
}}
>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
{/* Left side: Entity type badge + title */}

View File

@@ -8,7 +8,7 @@ export function QueueSkeleton({ count = 5 }: QueueSkeletonProps) {
return (
<div className="flex flex-col gap-6">
{Array.from({ length: count }).map((_, i) => (
<QueueItemSkeleton key={i} />
<QueueItemSkeleton key={i} index={i} />
))}
</div>
);

View File

@@ -353,3 +353,42 @@ All colors MUST be HSL.
border-color: hsl(var(--border));
}
}
/* Smooth queue transitions */
.queue-fade-enter {
animation: queueFadeIn 300ms ease-in-out;
}
.queue-fade-exit {
animation: queueFadeOut 200ms ease-in-out;
}
@keyframes queueFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes queueFadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
/* Performance optimizations for queue items */
.queue-item-container {
contain: layout style paint;
will-change: transform, opacity;
content-visibility: auto;
contain-intrinsic-size: 0 200px;
}