diff --git a/src/hooks/moderation/index.ts b/src/hooks/moderation/index.ts index 0af62212..48f2e2ea 100644 --- a/src/hooks/moderation/index.ts +++ b/src/hooks/moderation/index.ts @@ -8,3 +8,12 @@ export { useEntityCache } from './useEntityCache'; export { useProfileCache } 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'; diff --git a/src/hooks/moderation/useModerationFilters.ts b/src/hooks/moderation/useModerationFilters.ts new file mode 100644 index 00000000..1a300f61 --- /dev/null +++ b/src/hooks/moderation/useModerationFilters.ts @@ -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 + * + * ``` + */ +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( + persisted?.entityFilter || initialEntityFilter + ); + const [statusFilter, setStatusFilterState] = useState( + persisted?.statusFilter || initialStatusFilter + ); + const [activeTab, setActiveTabState] = useState( + 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, + }; +} diff --git a/src/hooks/moderation/useModerationSort.ts b/src/hooks/moderation/useModerationSort.ts new file mode 100644 index 00000000..35daef6e --- /dev/null +++ b/src/hooks/moderation/useModerationSort.ts @@ -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 + * + * ``` + */ +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(() => { + 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, + }; +} diff --git a/src/hooks/moderation/usePagination.ts b/src/hooks/moderation/usePagination.ts new file mode 100644 index 00000000..a929f767 --- /dev/null +++ b/src/hooks/moderation/usePagination.ts @@ -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( + persisted?.currentPage || initialPage + ); + const [pageSize, setPageSizeState] = useState( + persisted?.pageSize || initialPageSize + ); + const [totalCount, setTotalCount] = useState(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, + }; +} diff --git a/src/lib/moderation/index.ts b/src/lib/moderation/index.ts index 21e6163f..23ef81d8 100644 --- a/src/lib/moderation/index.ts +++ b/src/lib/moderation/index.ts @@ -42,3 +42,16 @@ export type { ModerationConfig, DeleteSubmissionConfig, } from './actions'; + +// Sorting utilities +export { + sortModerationItems, + getDefaultSortConfig, + loadSortConfig, + saveSortConfig, + toggleSortDirection, + getSortFieldLabel, + isDefaultSortConfig, +} from './sorting'; + +export type { SortConfig, SortField, SortDirection } from '@/types/moderation'; diff --git a/src/lib/moderation/sorting.ts b/src/lib/moderation/sorting.ts new file mode 100644 index 00000000..31a15512 --- /dev/null +++ b/src/lib/moderation/sorting.ts @@ -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 + ); +}