From c9aef311768a8202eebaf28608f2de9fd2cc5631 Mon Sep 17 00:00:00 2001 From: smartalee Date: Sun, 30 Aug 2026 10:25:10 -0400 Subject: [PATCH] feat: add admin moderation queue - Add admin dashboard with tabs for grants, updates, reports - Add approve/reject actions for each pending item - Add detail view for individual items - Add API endpoints for fetching and moderating items - Add table component for displaying moderation items - Show pending items with status badges - Loading and error states included Closes #159 --- .../src/app/admin/moderation/[id]/page.tsx | 126 ++++++++++++ frontend/src/app/admin/page.tsx | 189 ++++++++++++++++++ .../app/api/admin/moderation/item/route.ts | 43 ++++ .../src/app/api/admin/moderation/route.ts | 67 +++++++ frontend/src/components/ui/table.tsx | 117 +++++++++++ 5 files changed, 542 insertions(+) create mode 100644 frontend/src/app/admin/moderation/[id]/page.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/api/admin/moderation/item/route.ts create mode 100644 frontend/src/app/api/admin/moderation/route.ts create mode 100644 frontend/src/components/ui/table.tsx diff --git a/frontend/src/app/admin/moderation/[id]/page.tsx b/frontend/src/app/admin/moderation/[id]/page.tsx new file mode 100644 index 0000000..e642619 --- /dev/null +++ b/frontend/src/app/admin/moderation/[id]/page.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, ArrowLeft, CheckCircle, XCircle } from 'lucide-react'; + +export default function ModerationDetailPage() { + const params = useParams(); + const router = useRouter(); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState(false); + + useEffect(() => { + fetchItem(); + }, [params.id]); + + const fetchItem = async () => { + setLoading(true); + try { + const res = await fetch(`/api/admin/moderation/item?id=${params.id}`); + if (res.ok) { + const data = await res.json(); + setItem(data.item); + } + } catch (error) { + console.error('Failed to fetch item:', error); + } finally { + setLoading(false); + } + }; + + const handleModerate = async (action: 'approve' | 'reject') => { + setProcessing(true); + try { + const res = await fetch('/api/admin/moderation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: params.id, action, type: item?.type }), + }); + if (res.ok) { + router.push('/admin'); + } + } catch (error) { + console.error('Failed to moderate:', error); + } finally { + setProcessing(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!item) { + return ( +
+

Item not found

+ +
+ ); + } + + return ( +
+ + + + +
+
+ {item.title} + Submitted by {item.submittedBy} on {new Date(item.submittedAt).toLocaleString()} +
+ + {item.status || 'Pending'} + +
+
+ +
+

Details

+
+              {JSON.stringify(item.details, null, 2)}
+            
+
+ + {item.status === 'pending' && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..72a33f6 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Loader2, CheckCircle, XCircle, Clock, Eye } from 'lucide-react'; + +type PendingItem = { + id: string; + type: 'grant' | 'update' | 'report'; + title: string; + submittedBy: string; + submittedAt: string; + status: 'pending' | 'approved' | 'rejected'; + details: Record; +}; + +export default function AdminModerationPage() { + const router = useRouter(); + const [activeTab, setActiveTab] = useState('grants'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [processingId, setProcessingId] = useState(null); + + useEffect(() => { + fetchPendingItems(); + }, [activeTab]); + + const fetchPendingItems = async () => { + setLoading(true); + try { + const res = await fetch(`/api/admin/moderation?type=${activeTab}`); + if (res.ok) { + const data = await res.json(); + setItems(data.items || []); + } + } catch (error) { + console.error('Failed to fetch pending items:', error); + } finally { + setLoading(false); + } + }; + + const handleModerate = async (id: string, action: 'approve' | 'reject') => { + setProcessingId(id); + try { + const res = await fetch('/api/admin/moderation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, action, type: activeTab }), + }); + if (res.ok) { + setItems(items.filter(item => item.id !== id)); + } + } catch (error) { + console.error('Failed to moderate item:', error); + } finally { + setProcessingId(null); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'pending': return Pending; + case 'approved': return Approved; + case 'rejected': return Rejected; + default: return {status}; + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Moderation Queue

+

Review and manage pending grant submissions, updates, and user reports

+
+
+ {items.length} pending items +
+
+ + + + Grants ({items.filter(i => i.type === 'grant').length}) + Updates ({items.filter(i => i.type === 'update').length}) + Reports ({items.filter(i => i.type === 'report').length}) + + + + + + Pending {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} + Review and take action on pending submissions + + + {items.length === 0 ? ( +
+ +

No pending items

+

All {activeTab} have been reviewed

+
+ ) : ( +
+ + + + Title + Submitted By + Date + Status + Actions + + + + {items.map((item) => ( + + {item.title} + {item.submittedBy} + {new Date(item.submittedAt).toLocaleDateString()} + {getStatusBadge(item.status)} + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/api/admin/moderation/item/route.ts b/frontend/src/app/api/admin/moderation/item/route.ts new file mode 100644 index 0000000..9adb6c6 --- /dev/null +++ b/frontend/src/app/api/admin/moderation/item/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Reuse the same mock data as the main moderation route +const mockItems: Record = { + grants: [ + { id: '1', type: 'grant', title: 'Open Source Sustainability Grant', submittedBy: '0x123...abc', submittedAt: new Date().toISOString(), status: 'pending', details: { amount: '$5,000', category: 'Infrastructure' } }, + { id: '2', type: 'grant', title: 'Community Education Program', submittedBy: '0x456...def', submittedAt: new Date(Date.now() - 86400000).toISOString(), status: 'pending', details: { amount: '$3,000', category: 'Education' } }, + ], + updates: [ + { id: '3', type: 'update', title: 'Q3 Progress Update', submittedBy: '0x789...ghi', submittedAt: new Date(Date.now() - 172800000).toISOString(), status: 'pending', details: { project: 'Eco Initiative' } }, + ], + reports: [ + { id: '4', type: 'report', title: 'User Report: Inappropriate Content', submittedBy: '0xabc...jkl', submittedAt: new Date(Date.now() - 259200000).toISOString(), status: 'pending', details: { reason: 'Spam' } }, + ], +}; + +let pendingItems = { ...mockItems }; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'Missing id parameter' }, + { status: 400 } + ); + } + + // Search all types + for (const type of ['grants', 'updates', 'reports']) { + const items = pendingItems[type] || []; + const item = items.find((i: any) => i.id === id); + if (item) { + return NextResponse.json({ item }); + } + } + + return NextResponse.json( + { error: 'Item not found' }, + { status: 404 } + ); +} diff --git a/frontend/src/app/api/admin/moderation/route.ts b/frontend/src/app/api/admin/moderation/route.ts new file mode 100644 index 0000000..543d1a3 --- /dev/null +++ b/frontend/src/app/api/admin/moderation/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Mock data - replace with actual database queries +const mockItems: Record = { + grants: [ + { id: '1', type: 'grant', title: 'Open Source Sustainability Grant', submittedBy: '0x123...abc', submittedAt: new Date().toISOString(), status: 'pending', details: { amount: '$5,000', category: 'Infrastructure' } }, + { id: '2', type: 'grant', title: 'Community Education Program', submittedBy: '0x456...def', submittedAt: new Date(Date.now() - 86400000).toISOString(), status: 'pending', details: { amount: '$3,000', category: 'Education' } }, + ], + updates: [ + { id: '3', type: 'update', title: 'Q3 Progress Update', submittedBy: '0x789...ghi', submittedAt: new Date(Date.now() - 172800000).toISOString(), status: 'pending', details: { project: 'Eco Initiative' } }, + ], + reports: [ + { id: '4', type: 'report', title: 'User Report: Inappropriate Content', submittedBy: '0xabc...jkl', submittedAt: new Date(Date.now() - 259200000).toISOString(), status: 'pending', details: { reason: 'Spam' } }, + ], +}; + +// In-memory store - replace with database +let pendingItems = { ...mockItems }; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const type = searchParams.get('type') || 'grants'; + + const items = pendingItems[type] || []; + return NextResponse.json({ items }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { id, action, type } = body; + + if (!id || !action || !type) { + return NextResponse.json( + { error: 'Missing required fields: id, action, type' }, + { status: 400 } + ); + } + + const items = pendingItems[type] || []; + const index = items.findIndex((item: any) => item.id === id); + + if (index === -1) { + return NextResponse.json( + { error: 'Item not found' }, + { status: 404 } + ); + } + + // Update the item status + items[index].status = action === 'approve' ? 'approved' : 'rejected'; + pendingItems[type] = items; + + // In a real implementation, you would update the database here + + return NextResponse.json({ + success: true, + message: `Item ${action}d successfully`, + item: items[index], + }); + } catch (error) { + return NextResponse.json( + { error: 'Failed to process moderation action' }, + { status: 500 } + ); + } +} diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..7f3502f --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}