mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 16:51:13 -05:00
Refactor: Implement smooth moderation queue
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user