From baf889224c0ad3150b9d5a03ba7fd341390f061b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:18:14 +0000 Subject: [PATCH] feat: Implement tabbed dashboard layout --- src/components/moderation/ActivityCard.tsx | 115 ++++++++++++ src/components/moderation/RecentActivity.tsx | 176 +++++++++++++++++++ src/pages/AdminDashboard.tsx | 94 +++++++++- 3 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 src/components/moderation/ActivityCard.tsx create mode 100644 src/components/moderation/RecentActivity.tsx diff --git a/src/components/moderation/ActivityCard.tsx b/src/components/moderation/ActivityCard.tsx new file mode 100644 index 00000000..6d5c4098 --- /dev/null +++ b/src/components/moderation/ActivityCard.tsx @@ -0,0 +1,115 @@ +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { CheckCircle, XCircle, Flag, Shield, AlertCircle } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; + +interface ActivityCardProps { + activity: { + id: string; + type: 'submission' | 'report' | 'review'; + action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged'; + entity_type?: string; + entity_name?: string; + timestamp: string; + moderator_id?: string; + moderator?: { + username: string; + display_name?: string; + avatar_url?: string; + }; + }; +} + +export function ActivityCard({ activity }: ActivityCardProps) { + const getActionIcon = () => { + switch (activity.action) { + case 'approved': + return ; + case 'rejected': + return ; + case 'reviewed': + return ; + case 'dismissed': + return ; + case 'flagged': + return ; + default: + return ; + } + }; + + const getActionBadge = () => { + const variants = { + approved: 'default', + rejected: 'destructive', + reviewed: 'default', + dismissed: 'secondary', + flagged: 'secondary', + } as const; + + return ( + + {activity.action} + + ); + }; + + const getActivityTitle = () => { + const typeLabels = { + submission: 'Submission', + report: 'Report', + review: 'Review', + }; + + const entityTypeLabels = { + park: 'Park', + ride: 'Ride', + photo: 'Photo', + company: 'Company', + review: 'Review', + }; + + const entityLabel = activity.entity_type + ? entityTypeLabels[activity.entity_type as keyof typeof entityTypeLabels] || activity.entity_type + : typeLabels[activity.type]; + + return `${entityLabel} ${activity.action}`; + }; + + const moderatorName = activity.moderator?.display_name || activity.moderator?.username || 'Unknown Moderator'; + const moderatorInitial = moderatorName.charAt(0).toUpperCase(); + + return ( + +
+
+ {getActionIcon()} +
+ +
+
+

+ {getActivityTitle()} +

+ {getActionBadge()} +
+ +
+ + + {moderatorInitial} + + + by {moderatorName} + + + + {formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })} + +
+
+
+
+ ); +} diff --git a/src/components/moderation/RecentActivity.tsx b/src/components/moderation/RecentActivity.tsx new file mode 100644 index 00000000..c9f806e1 --- /dev/null +++ b/src/components/moderation/RecentActivity.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/use-toast'; +import { ActivityCard } from './ActivityCard'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Activity as ActivityIcon } from 'lucide-react'; + +interface ActivityItem { + id: string; + type: 'submission' | 'report' | 'review'; + action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged'; + entity_type?: string; + entity_name?: string; + timestamp: string; + moderator_id?: string; + moderator?: { + username: string; + display_name?: string; + avatar_url?: string; + }; +} + +export interface RecentActivityRef { + refresh: () => void; +} + +export const RecentActivity = forwardRef((props, ref) => { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const { user } = useAuth(); + const { toast } = useToast(); + + useImperativeHandle(ref, () => ({ + refresh: fetchRecentActivity + })); + + const fetchRecentActivity = async () => { + if (!user) return; + + try { + setLoading(true); + + // Fetch recent approved/rejected submissions + const { data: submissions, error: submissionsError } = await supabase + .from('content_submissions') + .select('id, status, reviewed_at, reviewer_id, submission_type') + .in('status', ['approved', 'rejected']) + .not('reviewed_at', 'is', null) + .order('reviewed_at', { ascending: false }) + .limit(15); + + if (submissionsError) throw submissionsError; + + // Fetch recent report resolutions + const { data: reports, error: reportsError } = await supabase + .from('reports') + .select('id, status, reviewed_at, reviewed_by, reported_entity_type') + .in('status', ['reviewed', 'dismissed']) + .not('reviewed_at', 'is', null) + .order('reviewed_at', { ascending: false }) + .limit(15); + + if (reportsError) throw reportsError; + + // Fetch recent review moderations + const { data: reviews, error: reviewsError } = await supabase + .from('reviews') + .select('id, moderation_status, moderated_at, moderated_by, park_id, ride_id') + .in('moderation_status', ['approved', 'rejected', 'flagged']) + .not('moderated_at', 'is', null) + .order('moderated_at', { ascending: false }) + .limit(15); + + if (reviewsError) throw reviewsError; + + // Get unique moderator IDs + const moderatorIds = [ + ...(submissions?.map(s => s.reviewer_id).filter(Boolean) || []), + ...(reports?.map(r => r.reviewed_by).filter(Boolean) || []), + ...(reviews?.map(r => r.moderated_by).filter(Boolean) || []), + ].filter((id, index, arr) => id && arr.indexOf(id) === index); + + // Fetch moderator profiles + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .in('user_id', moderatorIds); + + const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); + + // Combine all activities + const allActivities: ActivityItem[] = [ + ...(submissions?.map(s => ({ + id: s.id, + type: 'submission' as const, + action: s.status as 'approved' | 'rejected', + entity_type: s.submission_type, + timestamp: s.reviewed_at!, + moderator_id: s.reviewer_id, + moderator: s.reviewer_id ? profileMap.get(s.reviewer_id) : undefined, + })) || []), + ...(reports?.map(r => ({ + id: r.id, + type: 'report' as const, + action: r.status as 'reviewed' | 'dismissed', + entity_type: r.reported_entity_type, + timestamp: r.reviewed_at!, + moderator_id: r.reviewed_by, + moderator: r.reviewed_by ? profileMap.get(r.reviewed_by) : undefined, + })) || []), + ...(reviews?.map(r => ({ + id: r.id, + type: 'review' as const, + action: r.moderation_status as 'approved' | 'rejected' | 'flagged', + timestamp: r.moderated_at!, + moderator_id: r.moderated_by, + moderator: r.moderated_by ? profileMap.get(r.moderated_by) : undefined, + })) || []), + ]; + + // Sort by timestamp (newest first) + allActivities.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + setActivities(allActivities.slice(0, 20)); // Keep top 20 most recent + } catch (error: any) { + console.error('Error fetching recent activity:', error); + toast({ + title: "Error", + description: "Failed to load recent activity", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRecentActivity(); + }, [user]); + + if (loading) { + return ( +
+ + + + +
+ ); + } + + if (activities.length === 0) { + return ( +
+ +

No Recent Activity

+

+ Moderation activity will appear here once actions are taken. +

+
+ ); + } + + return ( +
+ {activities.map((activity) => ( + + ))} +
+ ); +}); + +RecentActivity.displayName = 'RecentActivity'; diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index ea524237..712798ff 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -1,10 +1,15 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FileText, Flag, AlertCircle } from 'lucide-react'; +import { FileText, Flag, AlertCircle, Activity } from 'lucide-react'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { AdminLayout } from '@/components/layout/AdminLayout'; +import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue'; +import { ReportsQueue } from '@/components/moderation/ReportsQueue'; +import { RecentActivity } from '@/components/moderation/RecentActivity'; import { useModerationStats } from '@/hooks/useModerationStats'; import { useAdminSettings } from '@/hooks/useAdminSettings'; @@ -13,6 +18,11 @@ export default function AdminDashboard() { const { isModerator, loading: roleLoading } = useUserRole(); const navigate = useNavigate(); const [isRefreshing, setIsRefreshing] = useState(false); + const [activeTab, setActiveTab] = useState('moderation'); + + const moderationQueueRef = useRef(null); + const reportsQueueRef = useRef(null); + const recentActivityRef = useRef(null); const { getAdminPanelRefreshMode, @@ -31,8 +41,36 @@ export default function AdminDashboard() { const handleRefresh = useCallback(async () => { setIsRefreshing(true); await refreshStats(); + + // Refresh active tab's content + switch (activeTab) { + case 'moderation': + moderationQueueRef.current?.refresh(); + break; + case 'reports': + reportsQueueRef.current?.refresh(); + break; + case 'activity': + recentActivityRef.current?.refresh(); + break; + } + setTimeout(() => setIsRefreshing(false), 500); - }, [refreshStats]); + }, [refreshStats, activeTab]); + + const handleStatCardClick = (cardType: 'submissions' | 'reports' | 'flagged') => { + switch (cardType) { + case 'submissions': + setActiveTab('moderation'); + break; + case 'reports': + setActiveTab('reports'); + break; + case 'flagged': + setActiveTab('moderation'); + break; + } + }; useEffect(() => { if (!authLoading && !roleLoading) { @@ -71,21 +109,21 @@ export default function AdminDashboard() { value: stats.pendingSubmissions, icon: FileText, color: 'amber', - link: '/admin/moderation', + type: 'submissions' as const, }, { label: 'Open Reports', value: stats.openReports, icon: Flag, color: 'red', - link: '/admin/reports', + type: 'reports' as const, }, { label: 'Flagged Content', value: stats.flaggedContent, icon: AlertCircle, color: 'orange', - link: '/admin/moderation', + type: 'flagged' as const, }, ]; @@ -101,7 +139,7 @@ export default function AdminDashboard() {

Admin Dashboard

- Overview of moderation activity and pending items + Central hub for all moderation activity

@@ -131,7 +169,7 @@ export default function AdminDashboard() { navigate(card.link)} + onClick={() => handleStatCardClick(card.type)} >
@@ -150,6 +188,48 @@ export default function AdminDashboard() { ); })}
+ + + + + + Moderation Queue + Queue + {stats.pendingSubmissions > 0 && ( + + {stats.pendingSubmissions} + + )} + + + + Reports + Reports + {stats.openReports > 0 && ( + + {stats.openReports} + + )} + + + + Recent Activity + Activity + + + + + + + + + + + + + + + );