mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:31:11 -05:00
758 lines
28 KiB
TypeScript
758 lines
28 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { useUserRole } from '@/hooks/useUserRole';
|
|
import { format } from 'date-fns';
|
|
|
|
interface ModerationItem {
|
|
id: string;
|
|
type: 'review' | 'content_submission';
|
|
content: any;
|
|
created_at: string;
|
|
user_id: string;
|
|
status: string;
|
|
submission_type?: string;
|
|
user_profile?: {
|
|
username: string;
|
|
display_name?: string;
|
|
};
|
|
}
|
|
|
|
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
|
type StatusFilter = 'all' | 'pending' | 'flagged' | 'approved' | 'rejected';
|
|
|
|
export function ModerationQueue() {
|
|
const [items, setItems] = useState<ModerationItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
|
const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all');
|
|
const [activeStatusFilter, setActiveStatusFilter] = useState<StatusFilter>('pending');
|
|
const { toast } = useToast();
|
|
const { isAdmin, isSuperuser } = useUserRole();
|
|
|
|
const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
let reviewStatuses: string[] = [];
|
|
let submissionStatuses: string[] = [];
|
|
|
|
// Define status filters
|
|
switch (statusFilter) {
|
|
case 'all':
|
|
reviewStatuses = ['pending', 'flagged', 'approved', 'rejected'];
|
|
submissionStatuses = ['pending', 'approved', 'rejected'];
|
|
break;
|
|
case 'pending':
|
|
reviewStatuses = ['pending'];
|
|
submissionStatuses = ['pending'];
|
|
break;
|
|
case 'flagged':
|
|
reviewStatuses = ['flagged'];
|
|
submissionStatuses = []; // Content submissions don't have flagged status
|
|
break;
|
|
case 'approved':
|
|
reviewStatuses = ['approved'];
|
|
submissionStatuses = ['approved'];
|
|
break;
|
|
case 'rejected':
|
|
reviewStatuses = ['rejected'];
|
|
submissionStatuses = ['rejected'];
|
|
break;
|
|
default:
|
|
reviewStatuses = ['pending', 'flagged'];
|
|
submissionStatuses = ['pending'];
|
|
}
|
|
|
|
// Fetch reviews
|
|
let reviews = [];
|
|
if ((entityFilter === 'all' || entityFilter === 'reviews') && reviewStatuses.length > 0) {
|
|
const { data: reviewsData, error: reviewsError } = await supabase
|
|
.from('reviews')
|
|
.select(`
|
|
id,
|
|
title,
|
|
content,
|
|
rating,
|
|
created_at,
|
|
user_id,
|
|
moderation_status,
|
|
photos
|
|
`)
|
|
.in('moderation_status', reviewStatuses)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (reviewsError) throw reviewsError;
|
|
reviews = reviewsData || [];
|
|
}
|
|
|
|
// Fetch content submissions
|
|
let submissions = [];
|
|
if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) {
|
|
let query = supabase
|
|
.from('content_submissions')
|
|
.select(`
|
|
id,
|
|
content,
|
|
submission_type,
|
|
created_at,
|
|
user_id,
|
|
status
|
|
`)
|
|
.in('status', submissionStatuses);
|
|
|
|
// Filter by submission type for photos
|
|
if (entityFilter === 'photos') {
|
|
query = query.eq('submission_type', 'photo');
|
|
} else if (entityFilter === 'submissions') {
|
|
query = query.neq('submission_type', 'photo');
|
|
}
|
|
|
|
const { data: submissionsData, error: submissionsError } = await query
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (submissionsError) throw submissionsError;
|
|
submissions = submissionsData || [];
|
|
}
|
|
|
|
// Get unique user IDs to fetch profiles
|
|
const userIds = [
|
|
...reviews.map(r => r.user_id),
|
|
...submissions.map(s => s.user_id)
|
|
];
|
|
|
|
// Fetch profiles for all users
|
|
const { data: profiles } = await supabase
|
|
.from('profiles')
|
|
.select('user_id, username, display_name')
|
|
.in('user_id', userIds);
|
|
|
|
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
|
|
|
|
// Combine and format items
|
|
const formattedItems: ModerationItem[] = [
|
|
...reviews.map(review => ({
|
|
id: review.id,
|
|
type: 'review' as const,
|
|
content: review,
|
|
created_at: review.created_at,
|
|
user_id: review.user_id,
|
|
status: review.moderation_status,
|
|
user_profile: profileMap.get(review.user_id),
|
|
})),
|
|
...submissions.map(submission => ({
|
|
id: submission.id,
|
|
type: 'content_submission' as const,
|
|
content: submission,
|
|
created_at: submission.created_at,
|
|
user_id: submission.user_id,
|
|
status: submission.status,
|
|
submission_type: submission.submission_type,
|
|
user_profile: profileMap.get(submission.user_id),
|
|
})),
|
|
];
|
|
|
|
// Sort by creation date (newest first for better UX)
|
|
formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
|
|
setItems(formattedItems);
|
|
} catch (error) {
|
|
console.error('Error fetching moderation items:', error);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to load moderation queue",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchItems(activeEntityFilter, activeStatusFilter);
|
|
}, [activeEntityFilter, activeStatusFilter]);
|
|
|
|
const handleModerationAction = async (
|
|
item: ModerationItem,
|
|
action: 'approved' | 'rejected',
|
|
moderatorNotes?: string
|
|
) => {
|
|
// Prevent multiple clicks on the same item
|
|
if (actionLoading === item.id) {
|
|
console.log('Action already in progress for item:', item.id);
|
|
return;
|
|
}
|
|
|
|
setActionLoading(item.id);
|
|
try {
|
|
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
|
|
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
|
|
|
|
// Use correct timestamp column name based on table
|
|
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
|
|
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
|
|
|
|
const updateData: any = {
|
|
[statusField]: action,
|
|
[timestampField]: new Date().toISOString(),
|
|
};
|
|
|
|
// Get current user ID for reviewer tracking
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (user) {
|
|
updateData[reviewerField] = user.id;
|
|
}
|
|
|
|
if (moderatorNotes) {
|
|
updateData.reviewer_notes = moderatorNotes;
|
|
}
|
|
|
|
console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table);
|
|
|
|
const { error, data } = await supabase
|
|
.from(table)
|
|
.update(updateData)
|
|
.eq('id', item.id)
|
|
.select();
|
|
|
|
if (error) {
|
|
console.error('Database update error:', error);
|
|
throw error;
|
|
}
|
|
|
|
console.log('Update response:', { data, rowsAffected: data?.length });
|
|
|
|
// Check if the update actually affected any rows
|
|
if (!data || data.length === 0) {
|
|
console.error('No rows were updated. This might be due to RLS policies or the item not existing.');
|
|
throw new Error('Failed to update item - no rows affected. You might not have permission to moderate this content.');
|
|
}
|
|
|
|
console.log('Update successful, rows affected:', data.length);
|
|
|
|
toast({
|
|
title: `Content ${action}`,
|
|
description: `The ${item.type} has been ${action}`,
|
|
});
|
|
|
|
// Only update local state if the database update was successful
|
|
setItems(prev => prev.map(i =>
|
|
i.id === item.id
|
|
? { ...i, status: action }
|
|
: i
|
|
));
|
|
|
|
// Clear notes only after successful update
|
|
setNotes(prev => {
|
|
const newNotes = { ...prev };
|
|
delete newNotes[item.id];
|
|
return newNotes;
|
|
});
|
|
|
|
// Only refresh if we're viewing a filter that should no longer show this item
|
|
if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) ||
|
|
(activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) {
|
|
console.log('Item no longer matches filter, removing from view');
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error('Error moderating content:', error);
|
|
|
|
// Revert any optimistic updates
|
|
setItems(prev => prev.map(i =>
|
|
i.id === item.id
|
|
? { ...i, status: item.status } // Revert to original status
|
|
: i
|
|
));
|
|
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || `Failed to ${action} content`,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteSubmission = async (item: ModerationItem) => {
|
|
if (item.type !== 'content_submission') return;
|
|
|
|
setActionLoading(item.id);
|
|
try {
|
|
// Step 1: Extract photo IDs from the submission content
|
|
const photoIds: string[] = [];
|
|
if (item.content?.photos && Array.isArray(item.content.photos)) {
|
|
for (const photo of item.content.photos) {
|
|
if (photo.imageId) {
|
|
photoIds.push(photo.imageId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Delete photos from Cloudflare Images (if any)
|
|
if (photoIds.length > 0) {
|
|
const deletePromises = photoIds.map(async (imageId) => {
|
|
try {
|
|
await supabase.functions.invoke('upload-image', {
|
|
method: 'DELETE',
|
|
body: { imageId }
|
|
});
|
|
} catch (deleteError) {
|
|
console.warn(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError);
|
|
// Continue with other deletions - don't fail the entire operation
|
|
}
|
|
});
|
|
|
|
// Execute all photo deletions in parallel
|
|
await Promise.allSettled(deletePromises);
|
|
}
|
|
|
|
// Step 3: Delete the submission from the database
|
|
const { error } = await supabase
|
|
.from('content_submissions')
|
|
.delete()
|
|
.eq('id', item.id);
|
|
|
|
if (error) throw error;
|
|
|
|
toast({
|
|
title: "Submission deleted",
|
|
description: `The submission and ${photoIds.length > 0 ? `${photoIds.length} associated photo(s) have` : 'has'} been permanently deleted`,
|
|
});
|
|
|
|
// Remove item from the current view
|
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
|
} catch (error) {
|
|
console.error('Error deleting submission:', error);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to delete submission",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const getStatusBadgeVariant = (status: string) => {
|
|
switch (status) {
|
|
case 'pending':
|
|
return 'secondary';
|
|
case 'flagged':
|
|
return 'destructive';
|
|
case 'approved':
|
|
return 'default';
|
|
case 'rejected':
|
|
return 'outline';
|
|
default:
|
|
return 'secondary';
|
|
}
|
|
};
|
|
|
|
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 '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.`;
|
|
}
|
|
};
|
|
|
|
const QueueContent = () => {
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="text-center py-8">
|
|
<CheckCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">No items found</h3>
|
|
<p className="text-muted-foreground">
|
|
{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{items.map((item) => (
|
|
<Card key={item.id} className={`border-l-4 ${
|
|
item.status === 'flagged' ? 'border-l-red-500' :
|
|
item.status === 'approved' ? 'border-l-green-500' :
|
|
item.status === 'rejected' ? 'border-l-red-400' :
|
|
'border-l-amber-500'
|
|
}`}>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Badge variant={getStatusBadgeVariant(item.status)}>
|
|
{item.type === 'review' ? (
|
|
<>
|
|
<MessageSquare className="w-3 h-3 mr-1" />
|
|
Review
|
|
</>
|
|
) : item.submission_type === 'photo' ? (
|
|
<>
|
|
<Image className="w-3 h-3 mr-1" />
|
|
Photo
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileText className="w-3 h-3 mr-1" />
|
|
Submission
|
|
</>
|
|
)}
|
|
</Badge>
|
|
<Badge variant={getStatusBadgeVariant(item.status)}>
|
|
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Calendar className="w-4 h-4" />
|
|
{format(new Date(item.created_at), 'MMM d, yyyy HH:mm')}
|
|
</div>
|
|
</div>
|
|
|
|
{item.user_profile && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<User className="w-4 h-4 text-muted-foreground" />
|
|
<span className="font-medium">
|
|
{item.user_profile.display_name || item.user_profile.username}
|
|
</span>
|
|
{item.user_profile.display_name && (
|
|
<span className="text-muted-foreground">
|
|
@{item.user_profile.username}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
{item.type === 'review' ? (
|
|
<div>
|
|
{item.content.title && (
|
|
<h4 className="font-semibold mb-2">{item.content.title}</h4>
|
|
)}
|
|
{item.content.content && (
|
|
<p className="text-sm mb-2">{item.content.content}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>Rating: {item.content.rating}/5</span>
|
|
</div>
|
|
{item.content.photos && item.content.photos.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="text-sm font-medium mb-2">Attached Photos:</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
{item.content.photos.map((photo: any, index: number) => (
|
|
<div key={index} className="relative">
|
|
<img
|
|
src={photo.url}
|
|
alt={`Review photo ${index + 1}`}
|
|
className="w-full h-20 object-cover rounded border"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : item.submission_type === 'photo' ? (
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-3">
|
|
Photo Submission
|
|
</div>
|
|
|
|
{/* Submission Title */}
|
|
{item.content.title && (
|
|
<div className="mb-3">
|
|
<div className="text-sm font-medium mb-1">Title:</div>
|
|
<p className="text-sm">{item.content.title}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submission Caption */}
|
|
{item.content.caption && (
|
|
<div className="mb-3">
|
|
<div className="text-sm font-medium mb-1">Caption:</div>
|
|
<p className="text-sm">{item.content.caption}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Photos */}
|
|
{item.content.photos && item.content.photos.length > 0 ? (
|
|
<div className="space-y-4">
|
|
<div className="text-sm font-medium">Photos ({item.content.photos.length}):</div>
|
|
{item.content.photos.map((photo: any, index: number) => (
|
|
<div key={index} className="border rounded-lg p-3 space-y-2">
|
|
<img
|
|
src={photo.url}
|
|
alt={`Photo ${index + 1}: ${photo.filename}`}
|
|
className="w-full max-h-64 object-contain rounded border bg-muted/30"
|
|
/>
|
|
<div className="space-y-1 text-xs text-muted-foreground">
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Filename:</span>
|
|
<span>{photo.filename || 'Unknown'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Size:</span>
|
|
<span>{photo.size ? `${Math.round(photo.size / 1024)} KB` : 'Unknown'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Type:</span>
|
|
<span>{photo.type || 'Unknown'}</span>
|
|
</div>
|
|
{photo.caption && (
|
|
<div className="pt-1">
|
|
<div className="font-medium">Caption:</div>
|
|
<div className="text-sm text-foreground mt-1">{photo.caption}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
No photos found in submission
|
|
</div>
|
|
)}
|
|
|
|
{/* Context Information */}
|
|
{item.content.context && (
|
|
<div className="mt-3 pt-3 border-t text-xs text-muted-foreground">
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Context:</span>
|
|
<span className="capitalize">{item.content.context}</span>
|
|
</div>
|
|
{item.content.ride_id && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Ride ID:</span>
|
|
<span className="font-mono">{item.content.ride_id}</span>
|
|
</div>
|
|
)}
|
|
{item.content.park_id && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium">Park ID:</span>
|
|
<span className="font-mono">{item.content.park_id}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="text-sm text-muted-foreground mb-2">
|
|
Type: {item.content.submission_type}
|
|
</div>
|
|
<pre className="text-sm whitespace-pre-wrap">
|
|
{JSON.stringify(item.content.content, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons based on status */}
|
|
{(item.status === 'pending' || item.status === 'flagged') && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label>
|
|
<Textarea
|
|
id={`notes-${item.id}`}
|
|
placeholder="Add notes about your moderation decision..."
|
|
value={notes[item.id] || ''}
|
|
onChange={(e) => setNotes(prev => ({ ...prev, [item.id]: e.target.value }))}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
onClick={() => handleModerationAction(item, 'approved', notes[item.id])}
|
|
disabled={actionLoading === item.id}
|
|
className="flex-1"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Approve
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => handleModerationAction(item, 'rejected', notes[item.id])}
|
|
disabled={actionLoading === item.id}
|
|
className="flex-1"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-2" />
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Delete button for rejected submissions (admin/superadmin only) */}
|
|
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && (
|
|
<div className="pt-2">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => handleDeleteSubmission(item)}
|
|
disabled={actionLoading === item.id}
|
|
className="w-full"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete Submission
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setActiveEntityFilter('all');
|
|
setActiveStatusFilter('pending');
|
|
};
|
|
|
|
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" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filter Bar */}
|
|
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-muted/50 rounded-lg">
|
|
<div className="flex flex-col sm:flex-row gap-4 flex-1">
|
|
<div className="space-y-2 min-w-[140px]">
|
|
<Label className="text-sm font-medium">Entity Type</Label>
|
|
<Select value={activeEntityFilter} onValueChange={(value) => setActiveEntityFilter(value as EntityFilter)}>
|
|
<SelectTrigger>
|
|
<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 min-w-[120px]">
|
|
<Label className="text-sm font-medium">Status</Label>
|
|
<Select value={activeStatusFilter} onValueChange={(value) => setActiveStatusFilter(value as StatusFilter)}>
|
|
<SelectTrigger>
|
|
<SelectValue>
|
|
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
|
<SelectItem value="flagged">Flagged</SelectItem>
|
|
)}
|
|
<SelectItem value="approved">Approved</SelectItem>
|
|
<SelectItem value="rejected">Rejected</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
|
|
<div className="flex items-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Clear Filters
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Active Filters Display */}
|
|
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>Active filters:</span>
|
|
{activeEntityFilter !== 'all' && (
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
{getEntityFilterIcon(activeEntityFilter)}
|
|
{activeEntityFilter}
|
|
</Badge>
|
|
)}
|
|
{activeStatusFilter !== 'pending' && (
|
|
<Badge variant="secondary" className="capitalize">
|
|
{activeStatusFilter}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Queue Content */}
|
|
<QueueContent />
|
|
</div>
|
|
);
|
|
} |