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; escalated?: boolean;
assigned_to?: string; assigned_to?: string;
locked_until?: string; locked_until?: string;
_removing?: boolean;
} }
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
@@ -81,8 +82,7 @@ export interface ModerationQueueRef {
export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => { export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [items, setItems] = useState<ModerationItem[]>([]); const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true); const [loadingState, setLoadingState] = useState<'initial' | 'loading' | 'refreshing' | 'ready'>('initial');
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<QueueTab>('mainQueue'); 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 [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set()); const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
const [newItemsCount, setNewItemsCount] = useState(0); const [newItemsCount, setNewItemsCount] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [profileCache, setProfileCache] = useState<Map<string, any>>(new Map()); const [profileCache, setProfileCache] = useState<Map<string, any>>(new Map());
const [entityCache, setEntityCache] = useState<{ const [entityCache, setEntityCache] = useState<{
rides: Map<string, any>, rides: Map<string, any>,
@@ -192,7 +191,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Enable transitions after initial render // Enable transitions after initial render
useEffect(() => { useEffect(() => {
if (!loading && items.length > 0 && !hasRenderedOnce) { if (loadingState === 'ready' && items.length > 0 && !hasRenderedOnce) {
// Use requestAnimationFrame to enable transitions AFTER first paint // Use requestAnimationFrame to enable transitions AFTER first paint
requestAnimationFrame(() => { requestAnimationFrame(() => {
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') => { const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false, tab: QueueTab = 'mainQueue') => {
if (!userRef.current) { if (!userRef.current) {
@@ -236,9 +235,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
try { try {
// Set loading states // Set loading states
if (!silent) { if (!silent) {
setLoading(true); setLoadingState('loading');
} else { } else {
setIsRefreshing(true); setLoadingState('refreshing');
} }
// Build base query for content submissions // Build base query for content submissions
@@ -681,15 +680,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}); });
} finally { } finally {
fetchInProgressRef.current = false; fetchInProgressRef.current = false;
setLoading(false); setLoadingState('ready');
setIsRefreshing(false);
setIsInitialLoad(false);
} }
}, []); // Empty deps - use refs instead }, []); // Empty deps - use refs instead
// Debounced filters to prevent rapid-fire calls // Debounced filters to prevent rapid-fire calls
const debouncedEntityFilter = useDebounce(activeEntityFilter, 1000); const debouncedEntityFilter = useDebounce(activeEntityFilter, 300);
const debouncedStatusFilter = useDebounce(activeStatusFilter, 1000); const debouncedStatusFilter = useDebounce(activeStatusFilter, 300);
// Store latest filter values in ref to avoid dependency issues // Store latest filter values in ref to avoid dependency issues
const filtersRef = useRef({ entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }); 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) // Polling for auto-refresh (only if realtime is disabled)
useEffect(() => { useEffect(() => {
// STRICT CHECK: Only enable polling if explicitly disabled // 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') { if (useRealtimeQueue && refreshMode === 'auto') {
console.log('✅ Polling DISABLED - using realtime subscriptions'); console.log('✅ Polling DISABLED - using realtime subscriptions');
} }
@@ -778,7 +775,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
console.log('🛑 Polling stopped'); console.log('🛑 Polling stopped');
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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) // Real-time subscription for NEW submissions (replaces polling)
useEffect(() => { useEffect(() => {
@@ -1199,22 +1196,28 @@ 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 UI update - batch with requestAnimationFrame for smoother rendering // Optimistic UI update with smooth exit animation
requestAnimationFrame(() => {
if (shouldRemove) { 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)); setItems(prev => prev.filter(i => i.id !== item.id));
// Mark as recently removed - ignore realtime updates for 10 seconds // Mark as recently removed - ignore realtime updates for 10 seconds
recentlyRemovedRef.current.add(item.id); recentlyRemovedRef.current.add(item.id);
setTimeout(() => { setTimeout(() => {
recentlyRemovedRef.current.delete(item.id); recentlyRemovedRef.current.delete(item.id);
}, 10000); // Increased from 3000 }, 10000);
}, 300);
} else { } else {
setItems(prev => prev.map(i => setItems(prev => prev.map(i =>
i.id === item.id ? { ...i, status: action } : 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) {
@@ -1836,9 +1839,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}, []); }, []);
const QueueContent = () => { const QueueContent = () => {
// Show skeleton during initial load OR during mounting phase // Show skeleton during ANY loading state (except refreshing)
if ((isInitialLoad && loading) || (isMountingRef.current && !initialFetchCompleteRef.current)) { if (loadingState === 'initial' || loadingState === 'loading') {
return <QueueSkeleton count={5} />; return (
<div className="animate-in fade-in-50 duration-200">
<QueueSkeleton count={Math.min(pageSize, 10)} />
</div>
);
} }
if (items.length === 0) { if (items.length === 0) {
@@ -1860,11 +1867,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return ( return (
<div <div
className="flex flex-col gap-6" className="flex flex-col gap-6 transition-opacity duration-300 ease-in-out queue-fade-enter"
data-initial-load={!hasRenderedOnce ? "true" : "false"} data-initial-load={!hasRenderedOnce ? "true" : "false"}
style={{ willChange: 'transform' }} style={{
willChange: 'transform, opacity'
}}
>
{sortedItems.map((item, index) => (
<div
key={item.id}
className="animate-in fade-in-0 slide-in-from-bottom-2"
style={{
animationDelay: hasRenderedOnce ? `${index * 30}ms` : '0ms',
animationDuration: '250ms',
animationFillMode: 'backwards'
}}
> >
{sortedItems.map((item) => (
<QueueItem <QueueItem
key={item.id} key={item.id}
item={item} item={item}
@@ -1888,6 +1906,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
onInteractionFocus={handleInteractionFocus} onInteractionFocus={handleInteractionFocus}
onInteractionBlur={handleInteractionBlur} onInteractionBlur={handleInteractionBlur}
/> />
</div>
))} ))}
</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={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}> <div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label> <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" : ""}> <SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectValue> <SelectValue>
<div className="flex items-center gap-2"> <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]'}`}> <div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label> <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" : ""}> <SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectValue> <SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span> <span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
@@ -2174,12 +2205,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant="default" variant="default"
size="sm" size="sm"
onClick={() => { onClick={() => {
// Merge pending new items into the main queue at the top // Smooth merge with loading state
if (pendingNewItems.length > 0) { if (pendingNewItems.length > 0) {
setLoadingState('loading');
// After 150ms, merge items
setTimeout(() => {
setItems(prev => [...pendingNewItems, ...prev]); setItems(prev => [...pendingNewItems, ...prev]);
setPendingNewItems([]); setPendingNewItems([]);
}
setNewItemsCount(0); setNewItemsCount(0);
// Show content again after brief pause
setTimeout(() => {
setLoadingState('ready');
}, 100);
}, 150);
}
console.log('✅ New items merged into queue'); console.log('✅ New items merged into queue');
}} }}
className="ml-4" className="ml-4"
@@ -2196,7 +2237,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<QueueContent /> <QueueContent />
{/* Pagination Controls */} {/* 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 justify-between border-t pt-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span> <span>
@@ -2208,8 +2249,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Select <Select
value={pageSize.toString()} value={pageSize.toString()}
onValueChange={(value) => { onValueChange={(value) => {
setLoadingState('loading');
setPageSize(parseInt(value)); setPageSize(parseInt(value));
setCurrentPage(1); setCurrentPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
> >
<SelectTrigger className="w-[120px] h-8"> <SelectTrigger className="w-[120px] h-8">
@@ -2231,7 +2274,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button <Button
variant="outline" variant="outline"
size="sm" 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} disabled={currentPage === 1}
> >
Previous Previous
@@ -2242,7 +2289,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button <Button
variant="outline" variant="outline"
size="sm" 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} disabled={currentPage === totalPages}
> >
Next Next
@@ -2253,7 +2304,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <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'} className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/> />
</PaginationItem> </PaginationItem>
@@ -2261,7 +2316,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{currentPage > 3 && ( {currentPage > 3 && (
<> <>
<PaginationItem> <PaginationItem>
<PaginationLink onClick={() => setCurrentPage(1)} isActive={currentPage === 1}> <PaginationLink
onClick={() => {
setLoadingState('loading');
setCurrentPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === 1}
>
1 1
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
@@ -2274,7 +2336,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.map(page => ( .map(page => (
<PaginationItem key={page}> <PaginationItem key={page}>
<PaginationLink <PaginationLink
onClick={() => setCurrentPage(page)} onClick={() => {
setLoadingState('loading');
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === page} isActive={currentPage === page}
> >
{page} {page}
@@ -2287,7 +2353,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<> <>
{currentPage < totalPages - 3 && <PaginationEllipsis />} {currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem> <PaginationItem>
<PaginationLink onClick={() => setCurrentPage(totalPages)} isActive={currentPage === totalPages}> <PaginationLink
onClick={() => {
setLoadingState('loading');
setCurrentPage(totalPages);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
isActive={currentPage === totalPages}
>
{totalPages} {totalPages}
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
@@ -2296,7 +2369,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<PaginationItem> <PaginationItem>
<PaginationNext <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'} className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/> />
</PaginationItem> </PaginationItem>

View File

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

View File

@@ -1,9 +1,16 @@
import { Card, CardHeader, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
export function QueueItemSkeleton() { export function QueueItemSkeleton({ index = 0 }: { index?: number }) {
return ( 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"> <CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
{/* Left side: Entity type badge + title */} {/* Left side: Entity type badge + title */}

View File

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

View File

@@ -353,3 +353,42 @@ All colors MUST be HSL.
border-color: hsl(var(--border)); 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;
}