feat: Implement database-level sorting for moderation queue

This commit is contained in:
gpt-engineer-app[bot]
2025-10-13 13:23:54 +00:00
parent 90ae7d9a41
commit 0af5443c81
8 changed files with 269 additions and 18 deletions

View File

@@ -109,15 +109,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
)}
{/* Filter Bar */}
<QueueFilters
activeEntityFilter={queueManager.filters.entityFilter}
activeStatusFilter={queueManager.filters.statusFilter}
isMobile={isMobile}
onEntityFilterChange={queueManager.filters.setEntityFilter}
onStatusFilterChange={queueManager.filters.setStatusFilter}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
/>
<QueueFilters
activeEntityFilter={queueManager.filters.entityFilter}
activeStatusFilter={queueManager.filters.statusFilter}
sortConfig={queueManager.sort.config}
isMobile={isMobile}
onEntityFilterChange={queueManager.filters.setEntityFilter}
onStatusFilterChange={queueManager.filters.setStatusFilter}
onSortChange={queueManager.sort.setConfig}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
/>
{/* Active Filters Display */}
{queueManager.filters.hasActiveFilters && (

View File

@@ -2,14 +2,17 @@ import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import type { EntityFilter, StatusFilter } from '@/types/moderation';
import { QueueSortControls } from './QueueSortControls';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
interface QueueFiltersProps {
activeEntityFilter: EntityFilter;
activeStatusFilter: StatusFilter;
sortConfig: SortConfig;
isMobile: boolean;
onEntityFilterChange: (filter: EntityFilter) => void;
onStatusFilterChange: (filter: StatusFilter) => void;
onSortChange: (config: SortConfig) => void;
onClearFilters: () => void;
showClearButton: boolean;
}
@@ -26,9 +29,11 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
export const QueueFilters = ({
activeEntityFilter,
activeStatusFilter,
sortConfig,
isMobile,
onEntityFilterChange,
onStatusFilterChange,
onSortChange,
onClearFilters,
showClearButton
}: QueueFiltersProps) => {
@@ -107,6 +112,13 @@ export const QueueFilters = ({
</SelectContent>
</Select>
</div>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
/>
</div>
{/* Clear Filters Button */}

View File

@@ -0,0 +1,78 @@
import { ArrowUp, ArrowDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import type { SortConfig, SortField } from '@/types/moderation';
interface QueueSortControlsProps {
sortConfig: SortConfig;
onSortChange: (config: SortConfig) => void;
isMobile: boolean;
}
const SORT_FIELD_LABELS: Record<SortField, string> = {
created_at: 'Date Submitted',
submission_type: 'Type',
status: 'Status'
};
export const QueueSortControls = ({
sortConfig,
onSortChange,
isMobile
}: QueueSortControlsProps) => {
const handleFieldChange = (field: SortField) => {
onSortChange({ ...sortConfig, field });
};
const handleDirectionToggle = () => {
onSortChange({
...sortConfig,
direction: sortConfig.direction === 'asc' ? 'desc' : 'asc'
});
};
const DirectionIcon = sortConfig.direction === 'asc' ? ArrowUp : ArrowDown;
return (
<div className={`flex gap-2 ${isMobile ? 'flex-col' : 'items-end'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[160px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>
Sort By
</Label>
<Select
value={sortConfig.field}
onValueChange={handleFieldChange}
>
<SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectValue>
{SORT_FIELD_LABELS[sortConfig.field]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(SORT_FIELD_LABELS).map(([field, label]) => (
<SelectItem key={field} value={field}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={isMobile ? "" : "pb-[2px]"}>
<Button
variant="outline"
size={isMobile ? "default" : "icon"}
onClick={handleDirectionToggle}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : 'h-10 w-10'}`}
title={sortConfig.direction === 'asc' ? 'Ascending' : 'Descending'}
>
<DirectionIcon className="w-4 h-4" />
{isMobile && (
<span className="capitalize">{sortConfig.direction}ending</span>
)}
</Button>
</div>
</div>
);
};

View File

@@ -17,6 +17,9 @@ export type { ModerationFilters, ModerationFiltersConfig } from './useModeration
export { usePagination } from './usePagination';
export type { PaginationState, PaginationConfig } from './usePagination';
export { useModerationSort } from './useModerationSort';
export type { UseModerationSortReturn } from './useModerationSort';
export { useRealtimeSubscriptions } from './useRealtimeSubscriptions';
export type {
RealtimeSubscriptionConfig,

View File

@@ -7,6 +7,7 @@ import {
useProfileCache,
useModerationFilters,
usePagination,
useModerationSort,
useRealtimeSubscriptions,
} from "./index";
import { useModerationQueue } from "@/hooks/useModerationQueue";
@@ -43,6 +44,7 @@ export interface ModerationQueueManager {
// Sub-hooks (exposed for granular control)
filters: ReturnType<typeof useModerationFilters>;
pagination: ReturnType<typeof usePagination>;
sort: ReturnType<typeof useModerationSort>;
queue: ReturnType<typeof useModerationQueue>;
// Realtime
@@ -97,7 +99,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
},
});
// Removed - sorting functionality deleted
const sort = useModerationSort();
const queue = useModerationQueue();
const entityCache = useEntityCache();
@@ -217,6 +219,21 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
`,
);
// CRITICAL: Multi-level ordering
// Level 1: Always sort by escalated first (descending)
submissionsQuery = submissionsQuery.order('escalated', { ascending: false });
// Level 2: Apply user-selected sort
submissionsQuery = submissionsQuery.order(
sort.config.field,
{ ascending: sort.config.direction === 'asc' }
);
// Level 3: Tertiary sort by created_at (if not already primary)
if (sort.config.field !== 'created_at') {
submissionsQuery = submissionsQuery.order('created_at', { ascending: true });
}
// Apply tab-based status filtering
const tab = filters.activeTab;
const statusFilter = filters.debouncedStatusFilter;
@@ -444,7 +461,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
setLoadingState("ready");
}
},
[user, isAdmin, isSuperuser, filters, pagination, profileCache, entityCache, toast],
[user, isAdmin, isSuperuser, filters, pagination, sort, profileCache, entityCache, toast],
);
// Store fetchItems in ref to avoid re-creating visibility listener
@@ -772,13 +789,13 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
// Filter changes trigger refetch
// Filter and sort changes trigger refetch
useEffect(() => {
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
pagination.reset();
fetchItems(true);
}, [filters.debouncedEntityFilter, filters.debouncedStatusFilter]);
}, [filters.debouncedEntityFilter, filters.debouncedStatusFilter, sort.config]);
// Pagination changes trigger refetch
useEffect(() => {
@@ -921,6 +938,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
actionLoading,
filters,
pagination,
sort,
queue,
newItemsCount,
pendingNewItems,

View File

@@ -0,0 +1,104 @@
import { useState, useCallback, useEffect } from 'react';
import type { SortConfig, SortField } from '@/types/moderation';
const STORAGE_KEY = 'moderationQueue_sortConfig';
/**
* Default sort configuration
* Sorts by creation date ascending (oldest first)
*/
const DEFAULT_SORT: SortConfig = {
field: 'created_at',
direction: 'asc'
};
/**
* Load sort configuration from localStorage
*/
function loadSortConfig(): SortConfig {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Validate structure
if (parsed.field && parsed.direction) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to load sort config:', error);
}
return DEFAULT_SORT;
}
/**
* Save sort configuration to localStorage
*/
function saveSortConfig(config: SortConfig): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.warn('Failed to save sort config:', error);
}
}
export interface UseModerationSortReturn {
/** Current sort configuration */
config: SortConfig;
/** Update the sort configuration */
setConfig: (config: SortConfig) => void;
/** Sort by a specific field, toggling direction if already sorting by that field */
sortBy: (field: SortField) => void;
/** Toggle the sort direction */
toggleDirection: () => void;
/** Reset to default sort */
reset: () => void;
}
/**
* Hook for managing moderation queue sort state
*
* Provides sort configuration with localStorage persistence
* and convenient methods for updating sort settings.
*/
export function useModerationSort(): UseModerationSortReturn {
const [config, setConfigState] = useState<SortConfig>(loadSortConfig);
// Persist to localStorage whenever config changes
useEffect(() => {
saveSortConfig(config);
}, [config]);
const setConfig = useCallback((newConfig: SortConfig) => {
setConfigState(newConfig);
}, []);
const sortBy = useCallback((field: SortField) => {
setConfigState(prev => ({
field,
// Toggle direction if clicking the same field, otherwise default to ascending
direction: prev.field === field
? (prev.direction === 'asc' ? 'desc' : 'asc')
: 'asc'
}));
}, []);
const toggleDirection = useCallback(() => {
setConfigState(prev => ({
...prev,
direction: prev.direction === 'asc' ? 'desc' : 'asc'
}));
}, []);
const reset = useCallback(() => {
setConfigState(DEFAULT_SORT);
}, []);
return {
config,
setConfig,
sortBy,
toggleDirection,
reset
};
}

View File

@@ -10,6 +10,7 @@ import type {
EntityFilter,
StatusFilter,
QueueTab,
SortConfig,
} from '@/types/moderation';
/**
@@ -24,6 +25,7 @@ export interface QueryConfig {
isSuperuser: boolean;
currentPage: number;
pageSize: number;
sortConfig?: SortConfig;
}
/**
@@ -73,9 +75,23 @@ export function buildSubmissionQuery(
item_data,
status
)
`)
.order('escalated', { ascending: false })
.order('created_at', { ascending: true });
`);
// CRITICAL: Multi-level ordering
// Level 1: Always sort by escalated first (descending) - escalated items always appear at top
query = query.order('escalated', { ascending: false });
// Level 2: Apply user-selected sort (if provided)
if (config.sortConfig) {
query = query.order(config.sortConfig.field, {
ascending: config.sortConfig.direction === 'asc'
});
}
// Level 3: Tertiary sort by created_at as tiebreaker (if not already primary sort)
if (!config.sortConfig || config.sortConfig.field !== 'created_at') {
query = query.order('created_at', { ascending: true });
}
// Apply tab-based status filtering
if (tab === 'mainQueue') {

View File

@@ -99,7 +99,25 @@ export type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged'
*/
export type QueueTab = 'mainQueue' | 'archive';
// Removed - sorting functionality deleted
/**
* Available fields for sorting the moderation queue
*/
export type SortField = 'created_at' | 'submission_type' | 'status';
/**
* Sort direction
*/
export type SortDirection = 'asc' | 'desc';
/**
* Configuration for sorting the moderation queue
*/
export interface SortConfig {
/** Field to sort by */
field: SortField;
/** Sort direction */
direction: SortDirection;
}
/**
* Loading states for the moderation queue