Files
thrilltrack-explorer/src/components/moderation/ModerationQueue.tsx
gpt-engineer-app[bot] b22546e7f2 Add audit trail and filters
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
2025-11-12 14:06:34 +00:00

754 lines
29 KiB
TypeScript

import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { AlertCircle, Info } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/errorHandler';
import { supabase } from '@/lib/supabaseClient';
import * as localStorage from '@/lib/localStorage';
import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager';
import { ItemEditDialog } from './ItemEditDialog';
import { ItemSelectorDialog } from './ItemSelectorDialog';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationQueueManager } from '@/hooks/moderation';
import { QueueItem } from './QueueItem';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { QueueSkeleton } from './QueueSkeleton';
import { EnhancedLockStatusDisplay } from './EnhancedLockStatusDisplay';
import { getLockStatus } from '@/lib/moderation/lockHelpers';
import { QueueStats } from './QueueStats';
import { QueueFilters } from './QueueFilters';
import { ActiveFiltersDisplay } from './ActiveFiltersDisplay';
import { AutoRefreshIndicator } from './AutoRefreshIndicator';
import { NewItemsAlert } from './NewItemsAlert';
import { EnhancedEmptyState } from './EnhancedEmptyState';
import { QueuePagination } from './QueuePagination';
import { ConfirmationDialog } from './ConfirmationDialog';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
import { SuperuserQueueControls } from './SuperuserQueueControls';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { fetchSubmissionItems, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import type { ModerationQueueRef, ModerationItem } from '@/types/moderation';
import type { PhotoItem } from '@/types/photos';
interface ModerationQueueProps {
optimisticallyUpdateStats?: (delta: Partial<{ pendingSubmissions: number; openReports: number; flaggedContent: number }>) => void;
}
export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueueProps>((props, ref) => {
const { optimisticallyUpdateStats } = props;
const isMobile = useIsMobile();
const { user } = useAuth();
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const adminSettings = useAdminSettings();
// Extract settings values to stable primitives for memoization
const refreshMode = adminSettings.getAdminPanelRefreshMode();
const pollInterval = adminSettings.getAdminPanelPollInterval();
const refreshStrategy = adminSettings.getAutoRefreshStrategy();
const preserveInteraction = adminSettings.getPreserveInteractionState();
const useRealtimeQueue = adminSettings.getUseRealtimeQueue();
// Memoize settings object using stable primitive dependencies
const settings = useMemo(() => ({
refreshMode,
pollInterval,
refreshStrategy,
preserveInteraction,
useRealtimeQueue,
}), [refreshMode, pollInterval, refreshStrategy, preserveInteraction, useRealtimeQueue]);
// Initialize queue manager (replaces all state management, fetchItems, effects)
const queueManager = useModerationQueueManager({
user,
isAdmin: isAdmin(),
isSuperuser: isSuperuser(),
toast,
optimisticallyUpdateStats,
settings,
});
// UI-only state
const [notes, setNotes] = useState<Record<string, string>>({});
const [transactionStatuses, setTransactionStatuses] = useState<Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>>(() => {
// Restore from localStorage on mount
return localStorage.getJSON('moderation-queue-transaction-statuses', {});
});
const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [showItemEditDialog, setShowItemEditDialog] = useState(false);
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
const [showItemSelector, setShowItemSelector] = useState(false);
const [availableItems, setAvailableItems] = useState<SubmissionItemWithDeps[]>([]);
const [bulkEditMode, setBulkEditMode] = useState(false);
const [bulkEditItems, setBulkEditItems] = useState<SubmissionItemWithDeps[]>([]);
const [activeLocksCount, setActiveLocksCount] = useState(0);
const [lockRestored, setLockRestored] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
title: string;
description: string;
onConfirm: () => void;
}>({
open: false,
title: '',
description: '',
onConfirm: () => {},
});
// Keyboard shortcuts help dialog
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
// Offline detection state
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Persist transaction statuses to localStorage
useEffect(() => {
localStorage.setJSON('moderation-queue-transaction-statuses', transactionStatuses);
}, [transactionStatuses]);
// Offline detection effect
useEffect(() => {
const handleOnline = () => {
setIsOffline(false);
toast({
title: 'Connection Restored',
description: 'You are back online. Refreshing queue...',
});
queueManager.refresh();
};
const handleOffline = () => {
setIsOffline(true);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [queueManager, toast]);
// Auto-dismiss lock restored banner after 10 seconds
useEffect(() => {
if (lockRestored && queueManager.queue.currentLock) {
const timer = setTimeout(() => {
setLockRestored(false);
}, 10000); // Auto-dismiss after 10 seconds
return () => clearTimeout(timer);
}
}, [lockRestored, queueManager.queue.currentLock]);
// Fetch active locks count for superusers
const isSuperuserValue = isSuperuser();
useEffect(() => {
if (!isSuperuserValue) return;
const fetchActiveLocksCount = async () => {
const { count } = await supabase
.from('content_submissions')
.select('id', { count: 'exact', head: true })
.not('assigned_to', 'is', null)
.gt('locked_until', new Date().toISOString());
setActiveLocksCount(count || 0);
};
fetchActiveLocksCount();
// Refresh count periodically
const interval = setInterval(fetchActiveLocksCount, 30000); // Every 30s
return () => clearInterval(interval);
}, [isSuperuserValue]);
// Track if lock was restored from database
useEffect(() => {
if (!initialLoadComplete) {
setInitialLoadComplete(true);
return;
}
if (queueManager.queue.currentLock && !lockRestored) {
// If we have a lock after initial load but haven't claimed in this session
setLockRestored(true);
}
}, [queueManager.queue.currentLock, lockRestored, initialLoadComplete]);
// Virtual scrolling setup
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: queueManager.items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 420, // Estimated average height of QueueItem (card + spacing)
overscan: 3, // Render 3 items above/below viewport for smoother scrolling
enabled: queueManager.items.length > 10, // Only enable virtual scrolling for 10+ items
});
// UI-specific handlers
const handleNoteChange = (id: string, value: string) => {
setNotes(prev => ({ ...prev, [id]: value }));
};
// Transaction status helpers
const setTransactionStatus = useCallback((submissionId: string, status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed', message?: string) => {
setTransactionStatuses(prev => ({
...prev,
[submissionId]: { status, message }
}));
// Auto-clear completed/failed statuses after 5 seconds
if (status === 'completed' || status === 'failed') {
setTimeout(() => {
setTransactionStatuses(prev => {
const updated = { ...prev };
if (updated[submissionId]?.status === status) {
updated[submissionId] = { status: 'idle' };
}
return updated;
});
}, 5000);
}
}, []);
// Wrap performAction to track transaction status
const handlePerformAction = useCallback(async (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => {
setTransactionStatus(item.id, 'processing');
try {
await queueManager.performAction(item, action, notes);
setTransactionStatus(item.id, 'completed');
} catch (error: any) {
// Check for timeout
if (error?.type === 'timeout' || error?.message?.toLowerCase().includes('timeout')) {
setTransactionStatus(item.id, 'timeout', error.message);
}
// Check for cached/409
else if (error?.status === 409 || error?.message?.toLowerCase().includes('duplicate')) {
setTransactionStatus(item.id, 'cached', 'Using cached result from duplicate request');
}
// Generic failure
else {
setTransactionStatus(item.id, 'failed', error.message);
}
throw error; // Re-throw to allow normal error handling
}
}, [queueManager, setTransactionStatus]);
// Wrapped delete with confirmation
const handleDeleteSubmission = useCallback((item: ModerationItem) => {
setConfirmDialog({
open: true,
title: 'Delete Submission',
description: 'Are you sure you want to permanently delete this submission? This action cannot be undone.',
onConfirm: () => queueManager.deleteSubmission(item),
});
}, [queueManager]);
// Superuser force release lock
const handleSuperuserReleaseLock = useCallback(async (submissionId: string) => {
// Fetch lock details before releasing
const { data: submission } = await supabase
.from('content_submissions')
.select('assigned_to, locked_until')
.eq('id', submissionId)
.single();
await queueManager.queue.superuserReleaseLock(submissionId);
// Log to audit trail
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
await logAdminAction('moderation_lock_force_released', {
submission_id: submissionId,
original_moderator_id: submission?.assigned_to,
original_locked_until: submission?.locked_until,
});
// Refresh locks count and queue
setActiveLocksCount(prev => Math.max(0, prev - 1));
queueManager.refresh();
}, [queueManager]);
// Superuser clear all locks
const handleClearAllLocks = useCallback(async () => {
const count = await queueManager.queue.superuserReleaseAllLocks();
setActiveLocksCount(0);
// Force queue refresh
queueManager.refresh();
}, [queueManager]);
// Clear filters handler
const handleClearFilters = useCallback(() => {
queueManager.filters.clearFilters();
}, [queueManager.filters]);
// Keyboard shortcuts
const { shortcuts } = useKeyboardShortcuts({
shortcuts: [
{
key: '?',
handler: () => setShowShortcutsHelp(true),
description: 'Show keyboard shortcuts',
},
{
key: 'r',
handler: () => queueManager.refresh(),
description: 'Refresh queue',
},
{
key: 'k',
ctrlOrCmd: true,
handler: () => {
// Focus search/filter (if implemented)
document.querySelector<HTMLInputElement>('[data-filter-search]')?.focus();
},
description: 'Focus filters',
},
{
key: 'e',
handler: () => {
// Edit first claimed submission
const claimedItem = queueManager.items.find(item =>
queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)
);
if (claimedItem) {
handleOpenItemEditor(claimedItem.id);
}
},
description: 'Edit claimed submission',
},
],
enabled: true,
});
const handleOpenPhotos = (photos: PhotoItem[], index: number) => {
setSelectedPhotos(photos);
setSelectedPhotoIndex(index);
setPhotoModalOpen(true);
};
const handleOpenReviewManager = (submissionId: string) => {
setSelectedSubmissionId(submissionId);
setReviewManagerOpen(true);
};
const handleOpenItemEditor = async (submissionId: string) => {
try {
const items = await fetchSubmissionItems(submissionId);
if (!items || items.length === 0) {
toast({
title: 'No Items Found',
description: 'This submission has no items to edit',
variant: 'destructive',
});
return;
}
// Phase 3: Multi-item selector for submissions with multiple items
if (items.length > 1) {
setAvailableItems(items);
setShowItemSelector(true);
} else {
// Single item - edit directly
setEditingItem(items[0]);
setShowItemEditDialog(true);
}
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
const handleSelectItem = (item: SubmissionItemWithDeps) => {
setEditingItem(item);
setShowItemSelector(false);
setShowItemEditDialog(true);
};
const handleBulkEdit = async (submissionId: string) => {
try {
const items = await fetchSubmissionItems(submissionId);
if (!items || items.length === 0) {
toast({
title: 'No Items Found',
description: 'This submission has no items to edit',
variant: 'destructive',
});
return;
}
setBulkEditItems(items);
setBulkEditMode(true);
setShowItemEditDialog(true);
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
// Expose imperative API
useImperativeHandle(ref, () => ({
refresh: async () => {
await queueManager.refresh();
}
}));
return (
<div className="space-y-4">
{/* Offline Banner */}
{isOffline && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>No Internet Connection</AlertTitle>
<AlertDescription>
You're offline. The moderation queue will automatically sync when your connection is restored.
</AlertDescription>
</Alert>
)}
{/* Queue Statistics & Lock Status */}
{queueManager.queue.queueStats && (
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<QueueStats stats={queueManager.queue.queueStats} isMobile={isMobile} />
<EnhancedLockStatusDisplay
currentLock={queueManager.queue.currentLock}
queueStats={queueManager.queue.queueStats}
loading={queueManager.queue.isLoading}
onExtendLock={() => queueManager.queue.extendLock(queueManager.queue.currentLock?.submissionId || '')}
onReleaseLock={() => queueManager.queue.releaseLock(queueManager.queue.currentLock?.submissionId || '', false)}
getCurrentTime={() => new Date()}
/>
</div>
</CardContent>
</Card>
)}
{/* Superuser Queue Controls */}
{isSuperuser() && (
<SuperuserQueueControls
activeLocksCount={activeLocksCount}
onClearAllLocks={handleClearAllLocks}
isLoading={queueManager.queue.isLoading}
/>
)}
{/* Lock Restored Alert */}
{lockRestored && queueManager.queue.currentLock && (() => {
// Check if restored submission is in current queue
const restoredSubmissionInQueue = queueManager.items.some(
item => item.id === queueManager.queue.currentLock?.submissionId
);
if (!restoredSubmissionInQueue) return null;
// Calculate time remaining
const timeRemainingMs = queueManager.queue.currentLock.expiresAt.getTime() - Date.now();
const timeRemainingSec = Math.max(0, Math.floor(timeRemainingMs / 1000));
const isExpiringSoon = timeRemainingSec < 300; // Less than 5 minutes
return (
<Alert className={isExpiringSoon
? "border-orange-500/50 bg-orange-500/10"
: "border-blue-500/50 bg-blue-500/5"
}>
<Info className={isExpiringSoon
? "h-4 w-4 text-orange-600"
: "h-4 w-4 text-blue-600"
} />
<AlertTitle>
{isExpiringSoon
? `Lock Expiring Soon (${Math.floor(timeRemainingSec / 60)}m ${timeRemainingSec % 60}s)`
: "Active Claim Restored"
}
</AlertTitle>
<AlertDescription>
{isExpiringSoon
? "Your lock is about to expire. Complete your review or extend the lock."
: "Your previous claim was restored. You still have time to review this submission."
}
</AlertDescription>
</Alert>
);
})()}
{/* Filter Bar */}
<QueueFilters
activeEntityFilter={queueManager.filters.entityFilter}
activeStatusFilter={queueManager.filters.statusFilter}
sortConfig={queueManager.filters.sortConfig}
activeTab={queueManager.filters.activeTab}
approvalDateRange={queueManager.filters.approvalDateRange}
isMobile={isMobile ?? false}
isLoading={queueManager.loadingState === 'loading'}
onEntityFilterChange={queueManager.filters.setEntityFilter}
onStatusFilterChange={queueManager.filters.setStatusFilter}
onSortChange={queueManager.filters.setSortConfig}
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
onRefresh={queueManager.refresh}
isRefreshing={queueManager.loadingState === 'refreshing'}
/>
{/* Active Filters Display */}
{queueManager.filters.hasActiveFilters && (
<ActiveFiltersDisplay
entityFilter={queueManager.filters.entityFilter}
statusFilter={queueManager.filters.statusFilter}
approvalDateRange={queueManager.filters.approvalDateRange}
/>
)}
{/* Auto-refresh Indicator */}
{adminSettings.getAdminPanelRefreshMode() === 'auto' && (
<AutoRefreshIndicator
enabled={true}
intervalSeconds={Math.round(adminSettings.getAdminPanelPollInterval() / 1000)}
mode={adminSettings.getUseRealtimeQueue() ? 'realtime' : 'polling'}
/>
)}
{/* New Items Alert */}
{queueManager.newItemsCount > 0 && (
<NewItemsAlert
count={queueManager.newItemsCount}
onShowNewItems={queueManager.showNewItems}
/>
)}
{/* Queue Content */}
{queueManager.loadingState === 'loading' || queueManager.loadingState === 'initial' ? (
<QueueSkeleton count={queueManager.pagination.pageSize} />
) : queueManager.items.length === 0 ? (
<EnhancedEmptyState
entityFilter={queueManager.filters.entityFilter}
statusFilter={queueManager.filters.statusFilter}
onClearFilters={queueManager.filters.hasActiveFilters ? handleClearFilters : undefined}
/>
) : (
<TooltipProvider>
{queueManager.items.length <= 10 ? (
// Standard rendering for small lists (no virtual scrolling overhead)
<div className="space-y-6">
{queueManager.items.map((item) => (
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
<QueueItem
item={item}
isMobile={isMobile ?? false}
actionLoading={queueManager.actionLoading}
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to || null, item.locked_until || null)}
isLockedByOther={queueManager.queue.isLockedByOther(item.id, item.assigned_to || null, item.locked_until || null)}
lockStatus={getLockStatus({ assigned_to: item.assigned_to || null, locked_until: item.locked_until || null }, user?.id || '')}
currentLockSubmissionId={queueManager.queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onOpenItemEditor={handleOpenItemEditor}
onClaimSubmission={queueManager.queue.claimSubmission}
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
))}
</div>
) : (
// Virtual scrolling for large lists (10+ items)
<div
ref={parentRef}
className="overflow-auto"
style={{
height: '70vh',
contain: 'strict',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = queueManager.items[virtualItem.index];
return (
<div
key={item.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<ModerationErrorBoundary submissionId={item.id}>
<QueueItem
item={item}
isMobile={isMobile ?? false}
actionLoading={queueManager.actionLoading}
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to || null, item.locked_until || null)}
isLockedByOther={queueManager.queue.isLockedByOther(item.id, item.assigned_to || null, item.locked_until || null)}
lockStatus={getLockStatus({ assigned_to: item.assigned_to || null, locked_until: item.locked_until || null }, user?.id || '')}
currentLockSubmissionId={queueManager.queue.currentLock?.submissionId}
notes={notes}
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}
onOpenReviewManager={handleOpenReviewManager}
onOpenItemEditor={handleOpenItemEditor}
onClaimSubmission={queueManager.queue.claimSubmission}
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
</div>
);
})}
</div>
</div>
)}
</TooltipProvider>
)}
{/* Pagination */}
{queueManager.loadingState === 'ready' && queueManager.pagination.totalPages > 1 && (
<QueuePagination
currentPage={queueManager.pagination.currentPage}
totalPages={queueManager.pagination.totalPages}
pageSize={queueManager.pagination.pageSize}
totalCount={queueManager.pagination.totalCount}
isMobile={isMobile ?? false}
onPageChange={queueManager.pagination.setCurrentPage}
onPageSizeChange={queueManager.pagination.setPageSize}
/>
)}
{/* Modals */}
<PhotoModal
photos={selectedPhotos.map(photo => ({
...photo,
caption: photo.caption ?? undefined
}))}
initialIndex={selectedPhotoIndex}
isOpen={photoModalOpen}
onClose={() => setPhotoModalOpen(false)}
/>
{selectedSubmissionId && (
<SubmissionReviewManager
submissionId={selectedSubmissionId}
open={reviewManagerOpen}
onOpenChange={setReviewManagerOpen}
onComplete={() => {
queueManager.refresh();
setSelectedSubmissionId(null);
}}
/>
)}
{/* Phase 3: Item Selector Dialog */}
<ItemSelectorDialog
items={availableItems}
open={showItemSelector}
onOpenChange={setShowItemSelector}
onSelectItem={handleSelectItem}
onBulkEdit={() => {
setShowItemSelector(false);
setBulkEditItems(availableItems);
setBulkEditMode(true);
setShowItemEditDialog(true);
}}
/>
{/* Phase 4 & 5: Enhanced Item Edit Dialog */}
<ItemEditDialog
item={bulkEditMode ? null : editingItem}
items={bulkEditMode ? bulkEditItems : undefined}
open={showItemEditDialog}
onOpenChange={(open) => {
setShowItemEditDialog(open);
if (!open) {
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}
}}
onComplete={() => {
queueManager.refresh();
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}}
/>
{/* Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialog.open}
onOpenChange={(open) => setConfirmDialog(prev => ({ ...prev, open }))}
title={confirmDialog.title}
description={confirmDialog.description}
onConfirm={confirmDialog.onConfirm}
variant="destructive"
confirmLabel="Delete"
/>
{/* Keyboard Shortcuts Help */}
<KeyboardShortcutsHelp
open={showShortcutsHelp}
onOpenChange={setShowShortcutsHelp}
shortcuts={shortcuts}
/>
</div>
);
});
ModerationQueue.displayName = 'ModerationQueue';
export type { ModerationQueueRef } from '@/types/moderation';