diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 8b9f1cc6..07fe48ca 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -24,6 +24,7 @@ import { Progress } from '@/components/ui/progress'; import { QueueStatsDashboard } from './QueueStatsDashboard'; import { EscalationDialog } from './EscalationDialog'; import { ReassignDialog } from './ReassignDialog'; +import { smartMergeArray } from '@/lib/smartStateUpdate'; interface ModerationItem { id: string; @@ -75,15 +76,24 @@ export const ModerationQueue = forwardRef((props, ref) => { const [escalationDialogOpen, setEscalationDialogOpen] = useState(false); const [reassignDialogOpen, setReassignDialogOpen] = useState(false); const [selectedItemForAction, setSelectedItemForAction] = useState(null); + const [interactingWith, setInteractingWith] = useState>(new Set()); + const [newItemsCount, setNewItemsCount] = useState(0); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); const queue = useModerationQueue(); // Get admin settings for polling configuration - const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings(); + const { + getAdminPanelRefreshMode, + getAdminPanelPollInterval, + getAutoRefreshStrategy, + getPreserveInteractionState + } = useAdminSettings(); const refreshMode = getAdminPanelRefreshMode(); const pollInterval = getAdminPanelPollInterval(); + const refreshStrategy = getAutoRefreshStrategy(); + const preserveInteraction = getPreserveInteractionState(); // Expose refresh method via ref useImperativeHandle(ref, () => ({ @@ -346,7 +356,39 @@ export const ModerationQueue = forwardRef((props, ref) => { // Sort by creation date (newest first for better UX) formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - setItems(formattedItems); + // Use smart merging for silent refreshes if strategy is 'merge' + if (silent && refreshStrategy === 'merge') { + const mergeResult = smartMergeArray(items, formattedItems, { + compareFields: ['status', 'reviewed_at', 'reviewer_notes'], + preserveOrder: true, + addToTop: true, + }); + + // If there are changes and we should preserve interaction + if (mergeResult.hasChanges) { + // Filter out items user is interacting with + const protectedIds = preserveInteraction ? interactingWith : new Set(); + const mergedWithProtection = mergeResult.items.map(item => { + if (protectedIds.has(item.id)) { + // Find and preserve the current version of this item + const currentItem = items.find(i => i.id === item.id); + return currentItem || item; + } + return item; + }); + + setItems(mergedWithProtection); + + // Update new items count + if (mergeResult.changes.added.length > 0) { + setNewItemsCount(prev => prev + mergeResult.changes.added.length); + } + } + } else { + // Full replacement for non-silent refreshes or 'replace' strategy + setItems(formattedItems); + setNewItemsCount(0); + } } catch (error: any) { console.error('Error fetching moderation items:', error); console.error('Error details:', { @@ -1985,6 +2027,24 @@ export const ModerationQueue = forwardRef((props, ref) => { )} + {/* New Items Notification */} + {newItemsCount > 0 && ( +
+ +
+ )} + {/* Queue Content */} diff --git a/src/components/moderation/RecentActivity.tsx b/src/components/moderation/RecentActivity.tsx index c9f806e1..9b6a91de 100644 --- a/src/components/moderation/RecentActivity.tsx +++ b/src/components/moderation/RecentActivity.tsx @@ -5,6 +5,8 @@ import { useToast } from '@/hooks/use-toast'; import { ActivityCard } from './ActivityCard'; import { Skeleton } from '@/components/ui/skeleton'; import { Activity as ActivityIcon } from 'lucide-react'; +import { smartMergeArray } from '@/lib/smartStateUpdate'; +import { useAdminSettings } from '@/hooks/useAdminSettings'; interface ActivityItem { id: string; @@ -28,18 +30,25 @@ export interface RecentActivityRef { export const RecentActivity = forwardRef((props, ref) => { const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); + const [isSilentRefresh, setIsSilentRefresh] = useState(false); const { user } = useAuth(); const { toast } = useToast(); + const { getAutoRefreshStrategy } = useAdminSettings(); + const refreshStrategy = getAutoRefreshStrategy(); useImperativeHandle(ref, () => ({ - refresh: fetchRecentActivity + refresh: () => fetchRecentActivity(false) })); - const fetchRecentActivity = async () => { + const fetchRecentActivity = async (silent = false) => { if (!user) return; try { - setLoading(true); + if (!silent) { + setLoading(true); + } else { + setIsSilentRefresh(true); + } // Fetch recent approved/rejected submissions const { data: submissions, error: submissionsError } = await supabase @@ -124,7 +133,23 @@ export const RecentActivity = forwardRef((props, ref) => { new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); - setActivities(allActivities.slice(0, 20)); // Keep top 20 most recent + const recentActivities = allActivities.slice(0, 20); // Keep top 20 most recent + + // Use smart merging for silent refreshes if strategy is 'merge' + if (silent && refreshStrategy === 'merge') { + const mergeResult = smartMergeArray(activities, recentActivities, { + compareFields: ['timestamp', 'action'], + preserveOrder: false, + addToTop: true, + }); + + if (mergeResult.hasChanges) { + setActivities(mergeResult.items); + } + } else { + // Full replacement for non-silent refreshes or 'replace' strategy + setActivities(recentActivities); + } } catch (error: any) { console.error('Error fetching recent activity:', error); toast({ @@ -133,12 +158,15 @@ export const RecentActivity = forwardRef((props, ref) => { variant: "destructive", }); } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } + setIsSilentRefresh(false); } }; useEffect(() => { - fetchRecentActivity(); + fetchRecentActivity(false); }, [user]); if (loading) { diff --git a/src/components/moderation/ReportsQueue.tsx b/src/components/moderation/ReportsQueue.tsx index b07642f4..0e7f28e0 100644 --- a/src/components/moderation/ReportsQueue.tsx +++ b/src/components/moderation/ReportsQueue.tsx @@ -10,6 +10,7 @@ import { useToast } from '@/hooks/use-toast'; import { format } from 'date-fns'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useAuth } from '@/hooks/useAuth'; +import { smartMergeArray } from '@/lib/smartStateUpdate'; interface Report { id: string; @@ -49,13 +50,19 @@ export const ReportsQueue = forwardRef((props, ref) => { const [loading, setLoading] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true); const [actionLoading, setActionLoading] = useState(null); + const [newReportsCount, setNewReportsCount] = useState(0); const { toast } = useToast(); const { user } = useAuth(); // Get admin settings for polling configuration - const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings(); + const { + getAdminPanelRefreshMode, + getAdminPanelPollInterval, + getAutoRefreshStrategy + } = useAdminSettings(); const refreshMode = getAdminPanelRefreshMode(); const pollInterval = getAdminPanelPollInterval(); + const refreshStrategy = getAutoRefreshStrategy(); // Expose refresh method via ref useImperativeHandle(ref, () => ({ @@ -120,7 +127,27 @@ export const ReportsQueue = forwardRef((props, ref) => { }) ); - setReports(reportsWithContent); + // Use smart merging for silent refreshes if strategy is 'merge' + if (silent && refreshStrategy === 'merge') { + const mergeResult = smartMergeArray(reports, reportsWithContent, { + compareFields: ['status'], + preserveOrder: true, + addToTop: true, + }); + + if (mergeResult.hasChanges) { + setReports(mergeResult.items); + + // Update new reports count + if (mergeResult.changes.added.length > 0) { + setNewReportsCount(prev => prev + mergeResult.changes.added.length); + } + } + } else { + // Full replacement for non-silent refreshes or 'replace' strategy + setReports(reportsWithContent); + setNewReportsCount(0); + } } catch (error) { console.error('Error fetching reports:', error); toast({ @@ -213,6 +240,24 @@ export const ReportsQueue = forwardRef((props, ref) => { return (
+ {/* New Reports Notification */} + {newReportsCount > 0 && ( +
+ +
+ )} + {reports.map((report) => ( diff --git a/src/hooks/useAdminSettings.ts b/src/hooks/useAdminSettings.ts index 882f53aa..d5f3ac2c 100644 --- a/src/hooks/useAdminSettings.ts +++ b/src/hooks/useAdminSettings.ts @@ -126,6 +126,30 @@ export function useAdminSettings() { return parseInt(value?.toString() || '30') * 1000; // Convert to milliseconds }; + /** + * Get auto-refresh strategy setting + * Returns: 'merge' | 'replace' | 'notify' + */ + const getAutoRefreshStrategy = (): 'merge' | 'replace' | 'notify' => { + const value = getSettingValue('auto_refresh_strategy', 'merge'); + const cleanValue = typeof value === 'string' ? value.replace(/"/g, '') : value; + return cleanValue as 'merge' | 'replace' | 'notify'; + }; + + /** + * Get preserve interaction state setting + * Returns: boolean + */ + const getPreserveInteractionState = (): boolean => { + const value = getSettingValue('preserve_interaction_state', 'true'); + const cleanValue = typeof value === 'string' ? value.replace(/"/g, '') : value; + return cleanValue === 'true' || cleanValue === true; + }; + + const getNotificationRecipients = () => { + return getSettingValue('notifications.recipients', []); + }; + return { settings, isLoading, @@ -140,10 +164,13 @@ export function useAdminSettings() { getRequireApproval, getBanDurations, getEmailAlertsEnabled, + getNotificationRecipients, getReportThreshold, getAuditRetentionDays, getAutoCleanupEnabled, getAdminPanelRefreshMode, getAdminPanelPollInterval, + getAutoRefreshStrategy, + getPreserveInteractionState, }; } \ No newline at end of file diff --git a/supabase/migrations/20251006183359_7162757b-0dab-4e64-9394-4f99ab0a4877.sql b/supabase/migrations/20251006183359_7162757b-0dab-4e64-9394-4f99ab0a4877.sql new file mode 100644 index 00000000..f464996c --- /dev/null +++ b/supabase/migrations/20251006183359_7162757b-0dab-4e64-9394-4f99ab0a4877.sql @@ -0,0 +1,6 @@ +-- Add admin settings for non-disruptive auto-refresh +INSERT INTO admin_settings (setting_key, setting_value, category, description) +VALUES + ('auto_refresh_strategy', '"merge"', 'admin_panel', 'Auto-refresh strategy: merge (silently merge new items), replace (current behavior - full replacement), notify (show notification only)'), + ('preserve_interaction_state', '"true"', 'admin_panel', 'Whether to preserve user interaction state during auto-refresh (expanded items, typing in notes, etc.)') +ON CONFLICT (setting_key) DO NOTHING; \ No newline at end of file