feat: Extract UI sub-components from ModerationQueue

This commit is contained in:
gpt-engineer-app[bot]
2025-10-12 23:10:40 +00:00
parent 0d0e352a1e
commit 5a84e2a469
9 changed files with 675 additions and 410 deletions

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setLoadingState('loading');
setPageSize(parseInt(value));
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'); setLoadingState('loading');
setCurrentPage(page); setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
isActive={currentPage === page} onPageSizeChange={(size) => {
>
{page}
</PaginationLink>
</PaginationItem>
))
}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <PaginationEllipsis />}
<PaginationItem>
<PaginationLink
onClick={() => {
setLoadingState('loading'); setLoadingState('loading');
setCurrentPage(totalPages); setPageSize(size);
window.scrollTo({ top: 0, behavior: 'smooth' }); setCurrentPage(1);
}} }}
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 */}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};