mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:31:12 -05:00
feat: Extract UI sub-components from ModerationQueue
This commit is contained in:
71
src/components/moderation/ActiveFiltersDisplay.tsx
Normal file
71
src/components/moderation/ActiveFiltersDisplay.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Filter, MessageSquare, FileText, Image, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { EntityFilter, StatusFilter, SortConfig, SortField } from '@/types/moderation';
|
||||||
|
|
||||||
|
interface ActiveFiltersDisplayProps {
|
||||||
|
entityFilter: EntityFilter;
|
||||||
|
statusFilter: StatusFilter;
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
defaultEntityFilter?: EntityFilter;
|
||||||
|
defaultStatusFilter?: StatusFilter;
|
||||||
|
defaultSortField?: SortField;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'reviews': return <MessageSquare className="w-4 h-4" />;
|
||||||
|
case 'submissions': return <FileText className="w-4 h-4" />;
|
||||||
|
case 'photos': return <Image className="w-4 h-4" />;
|
||||||
|
default: return <Filter className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortFieldLabel = (field: SortField): string => {
|
||||||
|
switch (field) {
|
||||||
|
case 'username': return 'Submitter';
|
||||||
|
case 'submission_type': return 'Type';
|
||||||
|
case 'escalated': return 'Escalated';
|
||||||
|
case 'status': return 'Status';
|
||||||
|
case 'created_at': return 'Date';
|
||||||
|
default: return field;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveFiltersDisplay = ({
|
||||||
|
entityFilter,
|
||||||
|
statusFilter,
|
||||||
|
sortConfig,
|
||||||
|
defaultEntityFilter = 'all',
|
||||||
|
defaultStatusFilter = 'pending',
|
||||||
|
defaultSortField = 'created_at'
|
||||||
|
}: ActiveFiltersDisplayProps) => {
|
||||||
|
const hasActiveFilters =
|
||||||
|
entityFilter !== defaultEntityFilter ||
|
||||||
|
statusFilter !== defaultStatusFilter ||
|
||||||
|
sortConfig.field !== defaultSortField;
|
||||||
|
|
||||||
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Active filters:</span>
|
||||||
|
{entityFilter !== defaultEntityFilter && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
|
{getEntityFilterIcon(entityFilter)}
|
||||||
|
{entityFilter}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{statusFilter !== defaultStatusFilter && (
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{statusFilter}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sortConfig.field !== defaultSortField && (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
|
{sortConfig.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||||
|
Sort: {getSortFieldLabel(sortConfig.field)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/components/moderation/AutoRefreshIndicator.tsx
Normal file
24
src/components/moderation/AutoRefreshIndicator.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
interface AutoRefreshIndicatorProps {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalSeconds: number;
|
||||||
|
mode?: 'polling' | 'realtime';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutoRefreshIndicator = ({
|
||||||
|
enabled,
|
||||||
|
intervalSeconds,
|
||||||
|
mode = 'polling'
|
||||||
|
}: AutoRefreshIndicatorProps) => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span>Auto-refresh active</span>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Checking every {intervalSeconds}s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/components/moderation/EmptyQueueState.tsx
Normal file
51
src/components/moderation/EmptyQueueState.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { CheckCircle, LucideIcon } from 'lucide-react';
|
||||||
|
import type { EntityFilter, StatusFilter } from '@/types/moderation';
|
||||||
|
|
||||||
|
interface EmptyQueueStateProps {
|
||||||
|
entityFilter: EntityFilter;
|
||||||
|
statusFilter: StatusFilter;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title?: string;
|
||||||
|
customMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter): string => {
|
||||||
|
const entityLabel = entityFilter === 'all' ? 'items' :
|
||||||
|
entityFilter === 'reviews' ? 'reviews' :
|
||||||
|
entityFilter === 'photos' ? 'photos' : 'submissions';
|
||||||
|
|
||||||
|
switch (statusFilter) {
|
||||||
|
case 'pending':
|
||||||
|
return `No pending ${entityLabel} require moderation at this time.`;
|
||||||
|
case 'partially_approved':
|
||||||
|
return `No partially approved ${entityLabel} found.`;
|
||||||
|
case 'flagged':
|
||||||
|
return `No flagged ${entityLabel} found.`;
|
||||||
|
case 'approved':
|
||||||
|
return `No approved ${entityLabel} found.`;
|
||||||
|
case 'rejected':
|
||||||
|
return `No rejected ${entityLabel} found.`;
|
||||||
|
case 'all':
|
||||||
|
return `No ${entityLabel} found.`;
|
||||||
|
default:
|
||||||
|
return `No ${entityLabel} found for the selected filter.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyQueueState = ({
|
||||||
|
entityFilter,
|
||||||
|
statusFilter,
|
||||||
|
icon: Icon = CheckCircle,
|
||||||
|
title = 'No items found',
|
||||||
|
customMessage
|
||||||
|
}: EmptyQueueStateProps) => {
|
||||||
|
const message = customMessage || getEmptyStateMessage(entityFilter, statusFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Icon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||||
|
<p className="text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef, useMemo } from 'react';
|
||||||
import { CheckCircle, XCircle, Filter, MessageSquare, FileText, Image, X, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap, ArrowUp, ArrowDown } from 'lucide-react';
|
import { CheckCircle, XCircle, AlertTriangle, UserCog, Zap } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from '@/components/ui/pagination';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
@@ -25,8 +11,6 @@ import { SubmissionReviewManager } from './SubmissionReviewManager';
|
|||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||||
import { useModerationQueue } from '@/hooks/useModerationQueue';
|
import { useModerationQueue } from '@/hooks/useModerationQueue';
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { QueueStatsDashboard } from './QueueStatsDashboard';
|
|
||||||
import { EscalationDialog } from './EscalationDialog';
|
import { EscalationDialog } from './EscalationDialog';
|
||||||
import { ReassignDialog } from './ReassignDialog';
|
import { ReassignDialog } from './ReassignDialog';
|
||||||
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
||||||
@@ -35,6 +19,13 @@ import { QueueItem } from './QueueItem';
|
|||||||
import { QueueSkeleton } from './QueueSkeleton';
|
import { QueueSkeleton } from './QueueSkeleton';
|
||||||
import { LockStatusDisplay } from './LockStatusDisplay';
|
import { LockStatusDisplay } from './LockStatusDisplay';
|
||||||
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
||||||
|
import { QueueStats } from './QueueStats';
|
||||||
|
import { QueueFilters } from './QueueFilters';
|
||||||
|
import { ActiveFiltersDisplay } from './ActiveFiltersDisplay';
|
||||||
|
import { AutoRefreshIndicator } from './AutoRefreshIndicator';
|
||||||
|
import { NewItemsAlert } from './NewItemsAlert';
|
||||||
|
import { EmptyQueueState } from './EmptyQueueState';
|
||||||
|
import { QueuePagination } from './QueuePagination';
|
||||||
import type {
|
import type {
|
||||||
ModerationItem,
|
ModerationItem,
|
||||||
EntityFilter,
|
EntityFilter,
|
||||||
@@ -1818,28 +1809,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter) => {
|
|
||||||
const entityLabel = entityFilter === 'all' ? 'items' :
|
|
||||||
entityFilter === 'reviews' ? 'reviews' :
|
|
||||||
entityFilter === 'photos' ? 'photos' : 'submissions';
|
|
||||||
|
|
||||||
switch (statusFilter) {
|
|
||||||
case 'pending':
|
|
||||||
return `No pending ${entityLabel} require moderation at this time.`;
|
|
||||||
case 'partially_approved':
|
|
||||||
return `No partially approved ${entityLabel} found.`;
|
|
||||||
case 'flagged':
|
|
||||||
return `No flagged ${entityLabel} found.`;
|
|
||||||
case 'approved':
|
|
||||||
return `No approved ${entityLabel} found.`;
|
|
||||||
case 'rejected':
|
|
||||||
return `No rejected ${entityLabel} found.`;
|
|
||||||
case 'all':
|
|
||||||
return `No ${entityLabel} found.`;
|
|
||||||
default:
|
|
||||||
return `No ${entityLabel} found for the selected filter.`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sort items function
|
// Sort items function
|
||||||
const sortItems = useCallback((items: ModerationItem[], config: SortConfig): ModerationItem[] => {
|
const sortItems = useCallback((items: ModerationItem[], config: SortConfig): ModerationItem[] => {
|
||||||
@@ -1932,13 +1901,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8">
|
<EmptyQueueState
|
||||||
<CheckCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
entityFilter={activeEntityFilter}
|
||||||
<h3 className="text-lg font-semibold mb-2">No items found</h3>
|
statusFilter={activeStatusFilter}
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2002,15 +1968,34 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
setSortConfig({ field: 'created_at', direction: 'asc' });
|
setSortConfig({ field: 'created_at', direction: 'asc' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEntityFilterIcon = (filter: EntityFilter) => {
|
const handleEntityFilterChange = (filter: EntityFilter) => {
|
||||||
switch (filter) {
|
setActiveEntityFilter(filter);
|
||||||
case 'reviews': return <MessageSquare className="w-4 h-4" />;
|
setLoadingState('loading');
|
||||||
case 'submissions': return <FileText className="w-4 h-4" />;
|
};
|
||||||
case 'photos': return <Image className="w-4 h-4" />;
|
|
||||||
default: return <Filter className="w-4 h-4" />;
|
const handleStatusFilterChange = (filter: StatusFilter) => {
|
||||||
|
setActiveStatusFilter(filter);
|
||||||
|
setLoadingState('loading');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortConfigChange = (config: SortConfig) => {
|
||||||
|
setSortConfig(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowNewItems = () => {
|
||||||
|
if (pendingNewItems.length > 0) {
|
||||||
|
setItems(prev => [...pendingNewItems, ...prev]);
|
||||||
|
setPendingNewItems([]);
|
||||||
|
setNewItemsCount(0);
|
||||||
|
console.log('✅ New items merged into queue:', pendingNewItems.length);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
activeEntityFilter !== 'all' ||
|
||||||
|
activeStatusFilter !== 'pending' ||
|
||||||
|
sortConfig.field !== 'created_at';
|
||||||
|
|
||||||
// Handle claim next action
|
// Handle claim next action
|
||||||
const handleClaimNext = async () => {
|
const handleClaimNext = async () => {
|
||||||
await queue.claimNext();
|
await queue.claimNext();
|
||||||
@@ -2024,25 +2009,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
|
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||||
{/* Stats Grid */}
|
<QueueStats stats={queue.queueStats} isMobile={isMobile} />
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 flex-1">
|
|
||||||
<div className="text-center sm:text-left">
|
|
||||||
<div className="text-2xl font-bold text-primary">{queue.queueStats.pendingCount}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Pending</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center sm:text-left">
|
|
||||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{queue.queueStats.assignedToMe}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Assigned to Me</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center sm:text-left">
|
|
||||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
|
||||||
{queue.queueStats.avgWaitHours.toFixed(1)}h
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Avg Wait</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Claim/Lock Status */}
|
|
||||||
<LockStatusDisplay
|
<LockStatusDisplay
|
||||||
currentLock={queue.currentLock}
|
currentLock={queue.currentLock}
|
||||||
queueStats={queue.queueStats}
|
queueStats={queue.queueStats}
|
||||||
@@ -2059,355 +2026,59 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter Bar */}
|
{/* Filter Bar */}
|
||||||
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
<QueueFilters
|
||||||
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
activeEntityFilter={activeEntityFilter}
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
activeStatusFilter={activeStatusFilter}
|
||||||
</div>
|
sortConfig={sortConfig}
|
||||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
isMobile={isMobile}
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
onEntityFilterChange={handleEntityFilterChange}
|
||||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
onStatusFilterChange={handleStatusFilterChange}
|
||||||
<Select
|
onSortConfigChange={handleSortConfigChange}
|
||||||
value={activeEntityFilter}
|
onClearFilters={clearFilters}
|
||||||
onValueChange={(value) => {
|
showClearButton={hasActiveFilters}
|
||||||
setActiveEntityFilter(value as EntityFilter);
|
/>
|
||||||
setLoadingState('loading');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
|
||||||
<SelectValue>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getEntityFilterIcon(activeEntityFilter)}
|
|
||||||
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
|
|
||||||
</div>
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="w-4 h-4" />
|
|
||||||
All Items
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="reviews">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MessageSquare className="w-4 h-4" />
|
|
||||||
Reviews
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="submissions">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
Submissions
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="photos">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Image className="w-4 h-4" />
|
|
||||||
Photos
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
|
||||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
|
||||||
<Select
|
|
||||||
value={activeStatusFilter}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setActiveStatusFilter(value as StatusFilter);
|
|
||||||
setLoadingState('loading');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
|
||||||
<SelectValue>
|
|
||||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="partially_approved">Partially Approved</SelectItem>
|
|
||||||
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
|
||||||
<SelectItem value="flagged">Flagged</SelectItem>
|
|
||||||
)}
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[180px]'}`}>
|
|
||||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Sort By</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
value={sortConfig.field}
|
|
||||||
onValueChange={(value) => setSortConfig(prev => ({ ...prev, field: value as SortField }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={isMobile ? "h-10 flex-1" : "flex-1"}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="created_at">Date Created</SelectItem>
|
|
||||||
<SelectItem value="username">Submitter</SelectItem>
|
|
||||||
<SelectItem value="submission_type">Type</SelectItem>
|
|
||||||
<SelectItem value="status">Status</SelectItem>
|
|
||||||
<SelectItem value="escalated">Escalated</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size={isMobile ? "default" : "sm"}
|
|
||||||
onClick={() => setSortConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
direction: prev.direction === 'asc' ? 'desc' : 'asc'
|
|
||||||
}))}
|
|
||||||
className={isMobile ? "h-10" : ""}
|
|
||||||
title={sortConfig.direction === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
|
|
||||||
>
|
|
||||||
{sortConfig.direction === 'asc' ? (
|
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
|
|
||||||
<div className={isMobile ? "" : "flex items-end"}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size={isMobile ? "default" : "sm"}
|
|
||||||
onClick={clearFilters}
|
|
||||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
{/* Active Filters Display */}
|
||||||
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending' || sortConfig.field !== 'created_at') && (
|
<ActiveFiltersDisplay
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
entityFilter={activeEntityFilter}
|
||||||
<span>Active filters:</span>
|
statusFilter={activeStatusFilter}
|
||||||
{activeEntityFilter !== 'all' && (
|
sortConfig={sortConfig}
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
/>
|
||||||
{getEntityFilterIcon(activeEntityFilter)}
|
|
||||||
{activeEntityFilter}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{activeStatusFilter !== 'pending' && (
|
|
||||||
<Badge variant="secondary" className="capitalize">
|
|
||||||
{activeStatusFilter}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{sortConfig.field !== 'created_at' && (
|
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
|
||||||
{sortConfig.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
|
||||||
Sort: {sortConfig.field === 'username' ? 'Submitter' :
|
|
||||||
sortConfig.field === 'submission_type' ? 'Type' :
|
|
||||||
sortConfig.field === 'escalated' ? 'Escalated' :
|
|
||||||
sortConfig.field === 'status' ? 'Status' : 'Date'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-refresh Status Indicator */}
|
{/* Auto-refresh Status Indicator */}
|
||||||
{refreshMode === 'auto' && (
|
<AutoRefreshIndicator
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
enabled={refreshMode === 'auto'}
|
||||||
<div className="flex items-center gap-1">
|
intervalSeconds={Math.round(pollInterval / 1000)}
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
mode={useRealtimeQueue ? 'realtime' : 'polling'}
|
||||||
<span>Auto-refresh active</span>
|
/>
|
||||||
</div>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Checking every {Math.round(pollInterval / 1000)}s</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New Items Notification - Enhanced */}
|
{/* New Items Notification */}
|
||||||
{newItemsCount > 0 && (
|
<NewItemsAlert
|
||||||
<div className="sticky top-0 z-10 animate-in fade-in-50">
|
count={newItemsCount}
|
||||||
<Alert className="border-primary/50 bg-primary/5">
|
onShowNewItems={handleShowNewItems}
|
||||||
<AlertCircle className="h-4 w-4 animate-pulse" />
|
/>
|
||||||
<AlertTitle>New Items Available</AlertTitle>
|
|
||||||
<AlertDescription className="flex items-center justify-between">
|
|
||||||
<span>{newItemsCount} new {newItemsCount === 1 ? 'submission' : 'submissions'} pending review</span>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
// Instant merge without loading state
|
|
||||||
if (pendingNewItems.length > 0) {
|
|
||||||
setItems(prev => [...pendingNewItems, ...prev]);
|
|
||||||
setPendingNewItems([]);
|
|
||||||
setNewItemsCount(0);
|
|
||||||
console.log('✅ New items merged into queue:', pendingNewItems.length);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="ml-4"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Show New Items
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Queue Content */}
|
{/* Queue Content */}
|
||||||
<QueueContent />
|
<QueueContent />
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
{totalPages > 1 && loadingState === 'ready' && (
|
{loadingState === 'ready' && (
|
||||||
<div className="flex items-center justify-between border-t pt-4 mt-6">
|
<QueuePagination
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
currentPage={currentPage}
|
||||||
<span>
|
totalPages={totalPages}
|
||||||
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount} items
|
pageSize={pageSize}
|
||||||
</span>
|
totalCount={totalCount}
|
||||||
{!isMobile && (
|
isMobile={isMobile}
|
||||||
<>
|
onPageChange={(page) => {
|
||||||
<span>•</span>
|
setLoadingState('loading');
|
||||||
<Select
|
setCurrentPage(page);
|
||||||
value={pageSize.toString()}
|
}}
|
||||||
onValueChange={(value) => {
|
onPageSizeChange={(size) => {
|
||||||
setLoadingState('loading');
|
setLoadingState('loading');
|
||||||
setPageSize(parseInt(value));
|
setPageSize(size);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
}}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[120px] h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="10">10 per page</SelectItem>
|
|
||||||
<SelectItem value="25">25 per page</SelectItem>
|
|
||||||
<SelectItem value="50">50 per page</SelectItem>
|
|
||||||
<SelectItem value="100">100 per page</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isMobile ? (
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(p => Math.max(1, p - 1));
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(p => Math.min(totalPages, p + 1));
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Pagination>
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(p => Math.max(1, p - 1));
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
{currentPage > 3 && (
|
|
||||||
<>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(1);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
isActive={currentPage === 1}
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
{currentPage > 4 && <PaginationEllipsis />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
||||||
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
|
|
||||||
.map(page => (
|
|
||||||
<PaginationItem key={page}>
|
|
||||||
<PaginationLink
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(page);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
isActive={currentPage === page}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
{currentPage < totalPages - 2 && (
|
|
||||||
<>
|
|
||||||
{currentPage < totalPages - 3 && <PaginationEllipsis />}
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(totalPages);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
isActive={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
{totalPages}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
onClick={() => {
|
|
||||||
setLoadingState('loading');
|
|
||||||
setCurrentPage(p => Math.min(totalPages, p + 1));
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photo Modal */}
|
{/* Photo Modal */}
|
||||||
|
|||||||
34
src/components/moderation/NewItemsAlert.tsx
Normal file
34
src/components/moderation/NewItemsAlert.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface NewItemsAlertProps {
|
||||||
|
count: number;
|
||||||
|
onShowNewItems: () => void;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewItemsAlert = ({ count, onShowNewItems, visible = true }: NewItemsAlertProps) => {
|
||||||
|
if (!visible || count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-10 animate-in fade-in-50">
|
||||||
|
<Alert className="border-primary/50 bg-primary/5">
|
||||||
|
<AlertCircle className="h-4 w-4 animate-pulse" />
|
||||||
|
<AlertTitle>New Items Available</AlertTitle>
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{count} new {count === 1 ? 'submission' : 'submissions'} pending review</span>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowNewItems}
|
||||||
|
className="ml-4"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Show New Items
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/components/moderation/QueueFilters.tsx
Normal file
140
src/components/moderation/QueueFilters.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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 { 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;
|
||||||
|
onSortConfigChange: (config: SortConfig) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
showClearButton: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'reviews': return <MessageSquare className="w-4 h-4" />;
|
||||||
|
case 'submissions': return <FileText className="w-4 h-4" />;
|
||||||
|
case 'photos': return <Image className="w-4 h-4" />;
|
||||||
|
default: return <Filter className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QueueFilters = ({
|
||||||
|
activeEntityFilter,
|
||||||
|
activeStatusFilter,
|
||||||
|
sortConfig,
|
||||||
|
isMobile,
|
||||||
|
onEntityFilterChange,
|
||||||
|
onStatusFilterChange,
|
||||||
|
onSortConfigChange,
|
||||||
|
onClearFilters,
|
||||||
|
showClearButton
|
||||||
|
}: QueueFiltersProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
||||||
|
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||||
|
{/* Entity Type Filter */}
|
||||||
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||||
|
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||||
|
<Select
|
||||||
|
value={activeEntityFilter}
|
||||||
|
onValueChange={onEntityFilterChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
||||||
|
<SelectValue>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getEntityFilterIcon(activeEntityFilter)}
|
||||||
|
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
|
||||||
|
</div>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
All Items
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="reviews">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
Reviews
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="submissions">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Submissions
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="photos">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
Photos
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
||||||
|
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={activeStatusFilter}
|
||||||
|
onValueChange={onStatusFilterChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
||||||
|
<SelectValue>
|
||||||
|
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="partially_approved">Partially Approved</SelectItem>
|
||||||
|
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
||||||
|
<SelectItem value="flagged">Flagged</SelectItem>
|
||||||
|
)}
|
||||||
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Controls */}
|
||||||
|
<QueueSortControls
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortConfigChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
{showClearButton && (
|
||||||
|
<div className={isMobile ? "" : "flex items-end"}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size={isMobile ? "default" : "sm"}
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
src/components/moderation/QueuePagination.tsx
Normal file
161
src/components/moderation/QueuePagination.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination';
|
||||||
|
|
||||||
|
interface QueuePaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
isMobile: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueuePagination = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
isMobile,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange
|
||||||
|
}: QueuePaginationProps) => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
onPageChange(page);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
onPageSizeChange(size);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = ((currentPage - 1) * pageSize) + 1;
|
||||||
|
const endItem = Math.min(currentPage * pageSize, totalCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-t pt-4 mt-6">
|
||||||
|
{/* Item Count & Page Size Selector */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Showing {startItem} - {endItem} of {totalCount} items
|
||||||
|
</span>
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<Select
|
||||||
|
value={pageSize.toString()}
|
||||||
|
onValueChange={(value) => handlePageSizeChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px] h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10 per page</SelectItem>
|
||||||
|
<SelectItem value="25">25 per page</SelectItem>
|
||||||
|
<SelectItem value="50">50 per page</SelectItem>
|
||||||
|
<SelectItem value="100">100 per page</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||||
|
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{currentPage > 3 && (
|
||||||
|
<>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => handlePageChange(1)}
|
||||||
|
isActive={currentPage === 1}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{currentPage > 4 && <PaginationEllipsis />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter(page => page >= currentPage - 2 && page <= currentPage + 2)
|
||||||
|
.map(page => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => handlePageChange(page)}
|
||||||
|
isActive={currentPage === page}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{currentPage < totalPages - 2 && (
|
||||||
|
<>
|
||||||
|
{currentPage < totalPages - 3 && <PaginationEllipsis />}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => handlePageChange(totalPages)}
|
||||||
|
isActive={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
src/components/moderation/QueueSortControls.tsx
Normal file
84
src/components/moderation/QueueSortControls.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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;
|
||||||
|
variant?: 'inline' | 'standalone';
|
||||||
|
showLabel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QueueSortControls = ({
|
||||||
|
sortConfig,
|
||||||
|
onSortChange,
|
||||||
|
isMobile = false,
|
||||||
|
variant = 'inline',
|
||||||
|
showLabel = true
|
||||||
|
}: QueueSortControlsProps) => {
|
||||||
|
const handleFieldChange = (field: SortField) => {
|
||||||
|
onSortChange({ ...sortConfig, field });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirectionToggle = () => {
|
||||||
|
onSortChange({
|
||||||
|
...sortConfig,
|
||||||
|
direction: sortConfig.direction === 'asc' ? 'desc' : 'asc'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[180px]'}`}>
|
||||||
|
{showLabel && (
|
||||||
|
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
||||||
|
Sort By
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={sortConfig.field}
|
||||||
|
onValueChange={handleFieldChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={isMobile ? "h-10 flex-1" : "flex-1"}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="created_at">{getSortFieldLabel('created_at')}</SelectItem>
|
||||||
|
<SelectItem value="username">{getSortFieldLabel('username')}</SelectItem>
|
||||||
|
<SelectItem value="submission_type">{getSortFieldLabel('submission_type')}</SelectItem>
|
||||||
|
<SelectItem value="status">{getSortFieldLabel('status')}</SelectItem>
|
||||||
|
<SelectItem value="escalated">{getSortFieldLabel('escalated')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size={isMobile ? "default" : "sm"}
|
||||||
|
onClick={handleDirectionToggle}
|
||||||
|
className={isMobile ? "h-10" : ""}
|
||||||
|
title={sortConfig.direction === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
|
||||||
|
>
|
||||||
|
{sortConfig.direction === 'asc' ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
src/components/moderation/QueueStats.tsx
Normal file
29
src/components/moderation/QueueStats.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface QueueStatsProps {
|
||||||
|
stats: {
|
||||||
|
pendingCount: number;
|
||||||
|
assignedToMe: number;
|
||||||
|
avgWaitHours: number;
|
||||||
|
};
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueueStats = ({ stats, isMobile }: QueueStatsProps) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 flex-1">
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="text-2xl font-bold text-primary">{stats.pendingCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.assignedToMe}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Assigned to Me</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{stats.avgWaitHours.toFixed(1)}h
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Wait</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user