diff --git a/src/App.tsx b/src/App.tsx index f1f9fab8..680015a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,8 @@ import SubmissionGuidelines from "./pages/SubmissionGuidelines"; const queryClient = new QueryClient(); +import Admin from "./pages/Admin"; + const App = () => ( @@ -39,6 +41,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index c32834f0..4527ecf7 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Search, Menu, Zap, MapPin, Star, ChevronDown, Building, Users, Crown, Palette } from 'lucide-react'; +import { Search, Menu, Zap, MapPin, Star, ChevronDown, Building, Users, Crown, Palette, Shield } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; @@ -9,8 +9,11 @@ import { Link, useNavigate } from 'react-router-dom'; import { SearchDropdown } from '@/components/search/SearchDropdown'; import { AuthButtons } from '@/components/auth/AuthButtons'; import { ThemeToggle } from '@/components/theme/ThemeToggle'; +import { useUserRole } from '@/hooks/useUserRole'; + export function Header() { const navigate = useNavigate(); + const { isModerator } = useUserRole(); return
{/* Logo and Brand */} @@ -67,6 +70,14 @@ export function Header() { {/* User Actions */}
+ {isModerator() && ( + + )} diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx new file mode 100644 index 00000000..7e03ba7f --- /dev/null +++ b/src/components/moderation/ModerationQueue.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect } from 'react'; +import { CheckCircle, XCircle, Eye, Calendar, User } 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 { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; +import { format } from 'date-fns'; + +interface ModerationItem { + id: string; + type: 'review' | 'content_submission'; + content: any; + created_at: string; + user_id: string; + status: string; + user_profile?: { + username: string; + display_name?: string; + }; +} + +export function ModerationQueue() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [notes, setNotes] = useState>({}); + const { toast } = useToast(); + + const fetchItems = async () => { + try { + // Fetch pending reviews with profile data + const { data: reviews, error: reviewsError } = await supabase + .from('reviews') + .select(` + id, + title, + content, + rating, + created_at, + user_id, + moderation_status + `) + .in('moderation_status', ['pending', 'flagged']) + .order('created_at', { ascending: true }); + + if (reviewsError) throw reviewsError; + + // Fetch pending content submissions + const { data: submissions, error: submissionsError } = await supabase + .from('content_submissions') + .select(` + id, + content, + submission_type, + created_at, + user_id, + status + `) + .eq('status', 'pending') + .order('created_at', { ascending: true }); + + if (submissionsError) throw submissionsError; + + // 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, + user_profile: profileMap.get(submission.user_id), + })), + ]; + + // Sort by creation date + formattedItems.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.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(); + }, []); + + const handleModerationAction = async ( + item: ModerationItem, + action: 'approved' | 'rejected', + moderatorNotes?: string + ) => { + setActionLoading(item.id); + try { + const table = item.type === 'review' ? 'reviews' : 'content_submissions'; + const statusField = item.type === 'review' ? 'moderation_status' : 'status'; + + const updateData: any = { + [statusField]: action, + [`moderated_at`]: new Date().toISOString(), + }; + + if (moderatorNotes) { + updateData.reviewer_notes = moderatorNotes; + } + + const { error } = await supabase + .from(table) + .update(updateData) + .eq('id', item.id); + + if (error) throw error; + + toast({ + title: `Content ${action}`, + description: `The ${item.type} has been ${action}`, + }); + + // Remove item from queue + setItems(prev => prev.filter(i => i.id !== item.id)); + + // Clear notes + setNotes(prev => { + const newNotes = { ...prev }; + delete newNotes[item.id]; + return newNotes; + }); + } catch (error) { + console.error('Error moderating content:', error); + toast({ + title: "Error", + description: `Failed to ${action} content`, + variant: "destructive", + }); + } finally { + setActionLoading(null); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (items.length === 0) { + return ( +
+ +

Queue is empty

+

+ No pending items require moderation at this time. +

+
+ ); + } + + return ( +
+ {items.map((item) => ( + + +
+
+ + {item.type === 'review' ? 'Review' : 'Submission'} + + {item.status === 'flagged' && ( + Flagged + )} +
+
+ + {format(new Date(item.created_at), 'MMM d, yyyy HH:mm')} +
+
+ + {item.user_profile && ( +
+ + + {item.user_profile.display_name || item.user_profile.username} + + {item.user_profile.display_name && ( + + @{item.user_profile.username} + + )} +
+ )} +
+ + +
+ {item.type === 'review' ? ( +
+ {item.content.title && ( +

{item.content.title}

+ )} + {item.content.content && ( +

{item.content.content}

+ )} +
+ Rating: {item.content.rating}/5 +
+
+ ) : ( +
+
+ Type: {item.content.submission_type} +
+
+                    {JSON.stringify(item.content.content, null, 2)}
+                  
+
+ )} +
+ +
+ +