mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 10:11:12 -05:00
feat: Implement database-level sorting for moderation queue
This commit is contained in:
@@ -112,9 +112,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
<QueueFilters
|
<QueueFilters
|
||||||
activeEntityFilter={queueManager.filters.entityFilter}
|
activeEntityFilter={queueManager.filters.entityFilter}
|
||||||
activeStatusFilter={queueManager.filters.statusFilter}
|
activeStatusFilter={queueManager.filters.statusFilter}
|
||||||
|
sortConfig={queueManager.sort.config}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||||
|
onSortChange={queueManager.sort.setConfig}
|
||||||
onClearFilters={queueManager.filters.clearFilters}
|
onClearFilters={queueManager.filters.clearFilters}
|
||||||
showClearButton={queueManager.filters.hasActiveFilters}
|
showClearButton={queueManager.filters.hasActiveFilters}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface QueueFiltersProps {
|
||||||
activeEntityFilter: EntityFilter;
|
activeEntityFilter: EntityFilter;
|
||||||
activeStatusFilter: StatusFilter;
|
activeStatusFilter: StatusFilter;
|
||||||
|
sortConfig: SortConfig;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||||
|
onSortChange: (config: SortConfig) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
showClearButton: boolean;
|
showClearButton: boolean;
|
||||||
}
|
}
|
||||||
@@ -26,9 +29,11 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
|||||||
export const QueueFilters = ({
|
export const QueueFilters = ({
|
||||||
activeEntityFilter,
|
activeEntityFilter,
|
||||||
activeStatusFilter,
|
activeStatusFilter,
|
||||||
|
sortConfig,
|
||||||
isMobile,
|
isMobile,
|
||||||
onEntityFilterChange,
|
onEntityFilterChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
|
onSortChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
showClearButton
|
showClearButton
|
||||||
}: QueueFiltersProps) => {
|
}: QueueFiltersProps) => {
|
||||||
@@ -107,6 +112,13 @@ export const QueueFilters = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Controls */}
|
||||||
|
<QueueSortControls
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters Button */}
|
{/* Clear Filters Button */}
|
||||||
|
|||||||
78
src/components/moderation/QueueSortControls.tsx
Normal file
78
src/components/moderation/QueueSortControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,9 @@ export type { ModerationFilters, ModerationFiltersConfig } from './useModeration
|
|||||||
export { usePagination } from './usePagination';
|
export { usePagination } from './usePagination';
|
||||||
export type { PaginationState, PaginationConfig } from './usePagination';
|
export type { PaginationState, PaginationConfig } from './usePagination';
|
||||||
|
|
||||||
|
export { useModerationSort } from './useModerationSort';
|
||||||
|
export type { UseModerationSortReturn } from './useModerationSort';
|
||||||
|
|
||||||
export { useRealtimeSubscriptions } from './useRealtimeSubscriptions';
|
export { useRealtimeSubscriptions } from './useRealtimeSubscriptions';
|
||||||
export type {
|
export type {
|
||||||
RealtimeSubscriptionConfig,
|
RealtimeSubscriptionConfig,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useProfileCache,
|
useProfileCache,
|
||||||
useModerationFilters,
|
useModerationFilters,
|
||||||
usePagination,
|
usePagination,
|
||||||
|
useModerationSort,
|
||||||
useRealtimeSubscriptions,
|
useRealtimeSubscriptions,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
||||||
@@ -43,6 +44,7 @@ export interface ModerationQueueManager {
|
|||||||
// Sub-hooks (exposed for granular control)
|
// Sub-hooks (exposed for granular control)
|
||||||
filters: ReturnType<typeof useModerationFilters>;
|
filters: ReturnType<typeof useModerationFilters>;
|
||||||
pagination: ReturnType<typeof usePagination>;
|
pagination: ReturnType<typeof usePagination>;
|
||||||
|
sort: ReturnType<typeof useModerationSort>;
|
||||||
queue: ReturnType<typeof useModerationQueue>;
|
queue: ReturnType<typeof useModerationQueue>;
|
||||||
|
|
||||||
// Realtime
|
// Realtime
|
||||||
@@ -97,7 +99,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Removed - sorting functionality deleted
|
const sort = useModerationSort();
|
||||||
|
|
||||||
const queue = useModerationQueue();
|
const queue = useModerationQueue();
|
||||||
const entityCache = useEntityCache();
|
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
|
// Apply tab-based status filtering
|
||||||
const tab = filters.activeTab;
|
const tab = filters.activeTab;
|
||||||
const statusFilter = filters.debouncedStatusFilter;
|
const statusFilter = filters.debouncedStatusFilter;
|
||||||
@@ -444,7 +461,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
setLoadingState("ready");
|
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
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
// Filter changes trigger refetch
|
// Filter and sort changes trigger refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
|
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
|
||||||
|
|
||||||
pagination.reset();
|
pagination.reset();
|
||||||
fetchItems(true);
|
fetchItems(true);
|
||||||
}, [filters.debouncedEntityFilter, filters.debouncedStatusFilter]);
|
}, [filters.debouncedEntityFilter, filters.debouncedStatusFilter, sort.config]);
|
||||||
|
|
||||||
// Pagination changes trigger refetch
|
// Pagination changes trigger refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -921,6 +938,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
actionLoading,
|
actionLoading,
|
||||||
filters,
|
filters,
|
||||||
pagination,
|
pagination,
|
||||||
|
sort,
|
||||||
queue,
|
queue,
|
||||||
newItemsCount,
|
newItemsCount,
|
||||||
pendingNewItems,
|
pendingNewItems,
|
||||||
|
|||||||
104
src/hooks/moderation/useModerationSort.ts
Normal file
104
src/hooks/moderation/useModerationSort.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
EntityFilter,
|
EntityFilter,
|
||||||
StatusFilter,
|
StatusFilter,
|
||||||
QueueTab,
|
QueueTab,
|
||||||
|
SortConfig,
|
||||||
} from '@/types/moderation';
|
} from '@/types/moderation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export interface QueryConfig {
|
|||||||
isSuperuser: boolean;
|
isSuperuser: boolean;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
sortConfig?: SortConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,9 +75,23 @@ export function buildSubmissionQuery(
|
|||||||
item_data,
|
item_data,
|
||||||
status
|
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
|
// Apply tab-based status filtering
|
||||||
if (tab === 'mainQueue') {
|
if (tab === 'mainQueue') {
|
||||||
|
|||||||
@@ -99,7 +99,25 @@ export type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged'
|
|||||||
*/
|
*/
|
||||||
export type QueueTab = 'mainQueue' | 'archive';
|
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
|
* Loading states for the moderation queue
|
||||||
|
|||||||
Reference in New Issue
Block a user