feat: Extract moderation filter and sort logic

This commit is contained in:
gpt-engineer-app[bot]
2025-10-12 22:38:30 +00:00
parent d979fa6e8b
commit 99a4d002ba
6 changed files with 762 additions and 0 deletions

View File

@@ -8,3 +8,12 @@
export { useEntityCache } from './useEntityCache'; export { useEntityCache } from './useEntityCache';
export { useProfileCache } from './useProfileCache'; export { useProfileCache } from './useProfileCache';
export type { CachedProfile } from './useProfileCache'; export type { CachedProfile } from './useProfileCache';
export { useModerationFilters } from './useModerationFilters';
export type { ModerationFilters, ModerationFiltersConfig } from './useModerationFilters';
export { useModerationSort } from './useModerationSort';
export type { ModerationSort, ModerationSortConfig } from './useModerationSort';
export { usePagination } from './usePagination';
export type { PaginationState, PaginationConfig } from './usePagination';

View File

@@ -0,0 +1,191 @@
/**
* Moderation Queue Filters Hook
*
* Manages filter state for the moderation queue, including:
* - Entity type filtering (all, reviews, submissions, photos)
* - Status filtering (pending, approved, rejected, etc.)
* - Tab management (main queue vs archive)
* - Filter persistence and clearing
*/
import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import type { EntityFilter, StatusFilter, QueueTab } from '@/types/moderation';
export interface ModerationFiltersConfig {
/** Initial entity filter */
initialEntityFilter?: EntityFilter;
/** Initial status filter */
initialStatusFilter?: StatusFilter;
/** Initial active tab */
initialTab?: QueueTab;
/** Debounce delay for filter changes (ms) */
debounceDelay?: number;
/** Whether to persist filters to localStorage */
persist?: boolean;
/** localStorage key prefix for persistence */
storageKey?: string;
}
export interface ModerationFilters {
/** Current entity type filter */
entityFilter: EntityFilter;
/** Current status filter */
statusFilter: StatusFilter;
/** Current active tab */
activeTab: QueueTab;
/** Debounced entity filter (for API calls) */
debouncedEntityFilter: EntityFilter;
/** Debounced status filter (for API calls) */
debouncedStatusFilter: StatusFilter;
/** Set entity filter */
setEntityFilter: (filter: EntityFilter) => void;
/** Set status filter */
setStatusFilter: (filter: StatusFilter) => void;
/** Set active tab */
setActiveTab: (tab: QueueTab) => void;
/** Reset all filters to defaults */
clearFilters: () => void;
/** Check if any non-default filters are active */
hasActiveFilters: boolean;
}
/**
* Hook for managing moderation queue filters
*
* @param config - Configuration options
* @returns Filter state and actions
*
* @example
* ```tsx
* const filters = useModerationFilters({
* persist: true,
* debounceDelay: 300
* });
*
* // Use in component
* <Select value={filters.entityFilter} onValueChange={filters.setEntityFilter}>
* ...
* </Select>
* ```
*/
export function useModerationFilters(config: ModerationFiltersConfig = {}): ModerationFilters {
const {
initialEntityFilter = 'all',
initialStatusFilter = 'pending',
initialTab = 'mainQueue',
debounceDelay = 300,
persist = true,
storageKey = 'moderationQueue_filters',
} = config;
// Load persisted filters on mount
const loadPersistedFilters = useCallback(() => {
if (!persist) return null;
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load persisted filters:', error);
}
return null;
}, [persist, storageKey]);
const persisted = loadPersistedFilters();
// Filter state
const [entityFilter, setEntityFilterState] = useState<EntityFilter>(
persisted?.entityFilter || initialEntityFilter
);
const [statusFilter, setStatusFilterState] = useState<StatusFilter>(
persisted?.statusFilter || initialStatusFilter
);
const [activeTab, setActiveTabState] = useState<QueueTab>(
persisted?.activeTab || initialTab
);
// Debounced filters for API calls
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
const debouncedStatusFilter = useDebounce(statusFilter, debounceDelay);
// Persist filters to localStorage
useEffect(() => {
if (persist) {
try {
localStorage.setItem(
storageKey,
JSON.stringify({
entityFilter,
statusFilter,
activeTab,
})
);
} catch (error) {
console.error('Failed to persist filters:', error);
}
}
}, [entityFilter, statusFilter, activeTab, persist, storageKey]);
// Set entity filter with logging
const setEntityFilter = useCallback((filter: EntityFilter) => {
console.log('🔍 Entity filter changed:', filter);
setEntityFilterState(filter);
}, []);
// Set status filter with logging
const setStatusFilter = useCallback((filter: StatusFilter) => {
console.log('🔍 Status filter changed:', filter);
setStatusFilterState(filter);
}, []);
// Set active tab with logging
const setActiveTab = useCallback((tab: QueueTab) => {
console.log('🔍 Tab changed:', tab);
setActiveTabState(tab);
}, []);
// Clear all filters
const clearFilters = useCallback(() => {
console.log('🔍 Filters cleared');
setEntityFilterState(initialEntityFilter);
setStatusFilterState(initialStatusFilter);
setActiveTabState(initialTab);
}, [initialEntityFilter, initialStatusFilter, initialTab]);
// Check if non-default filters are active
const hasActiveFilters =
entityFilter !== initialEntityFilter ||
statusFilter !== initialStatusFilter ||
activeTab !== initialTab;
return {
entityFilter,
statusFilter,
activeTab,
debouncedEntityFilter,
debouncedStatusFilter,
setEntityFilter,
setStatusFilter,
setActiveTab,
clearFilters,
hasActiveFilters,
};
}

View File

@@ -0,0 +1,145 @@
/**
* Moderation Queue Sort Hook
*
* Manages sort configuration for the moderation queue with persistence.
*/
import { useState, useCallback, useEffect } from 'react';
import type { SortConfig, SortField, SortDirection } from '@/types/moderation';
import {
getDefaultSortConfig,
loadSortConfig,
saveSortConfig,
toggleSortDirection as toggleDirection,
isDefaultSortConfig,
} from '@/lib/moderation/sorting';
export interface ModerationSortConfig {
/** Initial sort configuration */
initialConfig?: SortConfig;
/** Whether to persist sort config */
persist?: boolean;
/** localStorage key for persistence */
storageKey?: string;
/** Callback when sort config changes */
onChange?: (config: SortConfig) => void;
}
export interface ModerationSort {
/** Current sort configuration */
config: SortConfig;
/** Sort field */
field: SortField;
/** Sort direction */
direction: SortDirection;
/** Set sort field */
setField: (field: SortField) => void;
/** Set sort direction */
setDirection: (direction: SortDirection) => void;
/** Toggle sort direction */
toggleDirection: () => void;
/** Set both field and direction */
setConfig: (config: SortConfig) => void;
/** Reset to default */
reset: () => void;
/** Check if using default config */
isDefault: boolean;
}
/**
* Hook for managing moderation queue sort configuration
*
* @param config - Configuration options
* @returns Sort state and actions
*
* @example
* ```tsx
* const sort = useModerationSort({
* persist: true,
* onChange: (config) => fetchItems(config)
* });
*
* // Use in component
* <Select value={sort.field} onValueChange={sort.setField}>
* <SelectItem value="created_at">Date</SelectItem>
* <SelectItem value="username">User</SelectItem>
* </Select>
* ```
*/
export function useModerationSort(config: ModerationSortConfig = {}): ModerationSort {
const {
initialConfig,
persist = true,
storageKey = 'moderationQueue_sortConfig',
onChange,
} = config;
// Load persisted or use initial/default config
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
if (initialConfig) return initialConfig;
if (persist) return loadSortConfig(storageKey);
return getDefaultSortConfig();
});
// Persist changes
useEffect(() => {
if (persist) {
saveSortConfig(sortConfig, storageKey);
}
onChange?.(sortConfig);
}, [sortConfig, persist, storageKey, onChange]);
// Set sort field (keep direction)
const setField = useCallback((field: SortField) => {
setSortConfig((prev) => ({ ...prev, field }));
}, []);
// Set sort direction (keep field)
const setDirection = useCallback((direction: SortDirection) => {
setSortConfig((prev) => ({ ...prev, direction }));
}, []);
// Toggle sort direction
const toggleSortDirection = useCallback(() => {
setSortConfig((prev) => ({
...prev,
direction: toggleDirection(prev.direction),
}));
}, []);
// Set entire config
const setConfig = useCallback((newConfig: SortConfig) => {
setSortConfig(newConfig);
}, []);
// Reset to default
const reset = useCallback(() => {
setSortConfig(getDefaultSortConfig());
}, []);
// Check if using default config
const isDefault = isDefaultSortConfig(sortConfig);
return {
config: sortConfig,
field: sortConfig.field,
direction: sortConfig.direction,
setField,
setDirection,
toggleDirection: toggleSortDirection,
setConfig,
reset,
isDefault,
};
}

View File

@@ -0,0 +1,253 @@
/**
* Pagination Hook
*
* Manages pagination state and actions for the moderation queue.
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
export interface PaginationConfig {
/** Initial page number (1-indexed) */
initialPage?: number;
/** Initial page size */
initialPageSize?: number;
/** Whether to persist pagination state */
persist?: boolean;
/** localStorage key for persistence */
storageKey?: string;
/** Callback when page changes */
onPageChange?: (page: number) => void;
/** Callback when page size changes */
onPageSizeChange?: (pageSize: number) => void;
}
export interface PaginationState {
/** Current page (1-indexed) */
currentPage: number;
/** Items per page */
pageSize: number;
/** Total number of items */
totalCount: number;
/** Total number of pages */
totalPages: number;
/** Start index for current page (0-indexed) */
startIndex: number;
/** End index for current page (0-indexed) */
endIndex: number;
/** Whether there is a previous page */
hasPrevPage: boolean;
/** Whether there is a next page */
hasNextPage: boolean;
/** Set current page */
setCurrentPage: (page: number) => void;
/** Set page size */
setPageSize: (size: number) => void;
/** Set total count */
setTotalCount: (count: number) => void;
/** Go to next page */
nextPage: () => void;
/** Go to previous page */
prevPage: () => void;
/** Go to first page */
firstPage: () => void;
/** Go to last page */
lastPage: () => void;
/** Reset pagination */
reset: () => void;
/** Get page range for display */
getPageRange: (maxPages?: number) => number[];
}
/**
* Hook for managing pagination state
*
* @param config - Configuration options
* @returns Pagination state and actions
*
* @example
* ```tsx
* const pagination = usePagination({
* initialPageSize: 25,
* persist: true,
* onPageChange: (page) => fetchData(page)
* });
*
* // Set total count from API
* pagination.setTotalCount(response.count);
*
* // Use in query
* const { startIndex, endIndex } = pagination;
* query.range(startIndex, endIndex);
* ```
*/
export function usePagination(config: PaginationConfig = {}): PaginationState {
const {
initialPage = 1,
initialPageSize = 25,
persist = false,
storageKey = 'pagination_state',
onPageChange,
onPageSizeChange,
} = config;
// Load persisted state
const loadPersistedState = useCallback(() => {
if (!persist) return null;
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load pagination state:', error);
}
return null;
}, [persist, storageKey]);
const persisted = loadPersistedState();
// State
const [currentPage, setCurrentPageState] = useState<number>(
persisted?.currentPage || initialPage
);
const [pageSize, setPageSizeState] = useState<number>(
persisted?.pageSize || initialPageSize
);
const [totalCount, setTotalCount] = useState<number>(0);
// Computed values
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
const startIndex = useMemo(() => (currentPage - 1) * pageSize, [currentPage, pageSize]);
const endIndex = useMemo(() => startIndex + pageSize - 1, [startIndex, pageSize]);
const hasPrevPage = currentPage > 1;
const hasNextPage = currentPage < totalPages;
// Persist state
useEffect(() => {
if (persist) {
try {
localStorage.setItem(
storageKey,
JSON.stringify({
currentPage,
pageSize,
})
);
} catch (error) {
console.error('Failed to persist pagination state:', error);
}
}
}, [currentPage, pageSize, persist, storageKey]);
// Set current page with bounds checking
const setCurrentPage = useCallback(
(page: number) => {
const boundedPage = Math.max(1, Math.min(page, totalPages || 1));
setCurrentPageState(boundedPage);
onPageChange?.(boundedPage);
},
[totalPages, onPageChange]
);
// Set page size and reset to first page
const setPageSize = useCallback(
(size: number) => {
setPageSizeState(size);
setCurrentPageState(1);
onPageSizeChange?.(size);
},
[onPageSizeChange]
);
// Navigation actions
const nextPage = useCallback(() => {
if (hasNextPage) {
setCurrentPage(currentPage + 1);
}
}, [currentPage, hasNextPage, setCurrentPage]);
const prevPage = useCallback(() => {
if (hasPrevPage) {
setCurrentPage(currentPage - 1);
}
}, [currentPage, hasPrevPage, setCurrentPage]);
const firstPage = useCallback(() => {
setCurrentPage(1);
}, [setCurrentPage]);
const lastPage = useCallback(() => {
setCurrentPage(totalPages);
}, [totalPages, setCurrentPage]);
// Reset pagination
const reset = useCallback(() => {
setCurrentPageState(initialPage);
setPageSizeState(initialPageSize);
setTotalCount(0);
}, [initialPage, initialPageSize]);
// Get page range for pagination controls
const getPageRange = useCallback(
(maxPages: number = 5): number[] => {
if (totalPages <= maxPages) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const half = Math.floor(maxPages / 2);
let start = Math.max(1, currentPage - half);
let end = Math.min(totalPages, start + maxPages - 1);
// Adjust start if we're near the end
if (end - start < maxPages - 1) {
start = Math.max(1, end - maxPages + 1);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
},
[currentPage, totalPages]
);
return {
currentPage,
pageSize,
totalCount,
totalPages,
startIndex,
endIndex,
hasPrevPage,
hasNextPage,
setCurrentPage,
setPageSize,
setTotalCount,
nextPage,
prevPage,
firstPage,
lastPage,
reset,
getPageRange,
};
}

View File

@@ -42,3 +42,16 @@ export type {
ModerationConfig, ModerationConfig,
DeleteSubmissionConfig, DeleteSubmissionConfig,
} from './actions'; } from './actions';
// Sorting utilities
export {
sortModerationItems,
getDefaultSortConfig,
loadSortConfig,
saveSortConfig,
toggleSortDirection,
getSortFieldLabel,
isDefaultSortConfig,
} from './sorting';
export type { SortConfig, SortField, SortDirection } from '@/types/moderation';

View File

@@ -0,0 +1,151 @@
/**
* Moderation Queue Sorting Utilities
*
* Provides sorting functions and utilities for moderation queue items.
*/
import type { ModerationItem, SortField, SortDirection, SortConfig } from '@/types/moderation';
/**
* Sort moderation items based on configuration
*
* @param items - Array of moderation items to sort
* @param config - Sort configuration
* @returns Sorted array of items
*/
export function sortModerationItems(
items: ModerationItem[],
config: SortConfig
): ModerationItem[] {
const { field, direction } = config;
return [...items].sort((a, b) => {
let comparison = 0;
switch (field) {
case 'created_at':
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
case 'username':
const usernameA = a.user_profile?.username || a.user_profile?.display_name || '';
const usernameB = b.user_profile?.username || b.user_profile?.display_name || '';
comparison = usernameA.localeCompare(usernameB);
break;
case 'submission_type':
comparison = (a.submission_type || '').localeCompare(b.submission_type || '');
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'escalated':
const escalatedA = a.escalated ? 1 : 0;
const escalatedB = b.escalated ? 1 : 0;
comparison = escalatedB - escalatedA; // Escalated items first
break;
default:
comparison = 0;
}
return direction === 'asc' ? comparison : -comparison;
});
}
/**
* Get default sort configuration
*
* @returns Default sort config
*/
export function getDefaultSortConfig(): SortConfig {
return {
field: 'created_at',
direction: 'asc',
};
}
/**
* Load sort configuration from localStorage
*
* @param key - localStorage key
* @returns Saved sort config or default
*/
export function loadSortConfig(key: string = 'moderationQueue_sortConfig'): SortConfig {
try {
const saved = localStorage.getItem(key);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load sort config:', error);
}
return getDefaultSortConfig();
}
/**
* Save sort configuration to localStorage
*
* @param config - Sort configuration to save
* @param key - localStorage key
*/
export function saveSortConfig(
config: SortConfig,
key: string = 'moderationQueue_sortConfig'
): void {
try {
localStorage.setItem(key, JSON.stringify(config));
} catch (error) {
console.error('Failed to save sort config:', error);
}
}
/**
* Toggle sort direction
*
* @param currentDirection - Current sort direction
* @returns Toggled direction
*/
export function toggleSortDirection(currentDirection: SortDirection): SortDirection {
return currentDirection === 'asc' ? 'desc' : 'asc';
}
/**
* Get human-readable label for sort field
*
* @param field - Sort field
* @returns Human-readable label
*/
export function getSortFieldLabel(field: SortField): string {
switch (field) {
case 'created_at':
return 'Date Created';
case 'username':
return 'Submitter';
case 'submission_type':
return 'Type';
case 'status':
return 'Status';
case 'escalated':
return 'Escalated';
default:
return field;
}
}
/**
* Check if sort config is default
*
* @param config - Sort configuration to check
* @returns True if config matches default
*/
export function isDefaultSortConfig(config: SortConfig): boolean {
const defaultConfig = getDefaultSortConfig();
return (
config.field === defaultConfig.field &&
config.direction === defaultConfig.direction
);
}