Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions frontend/src/app/admin/moderation/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(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 (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}

if (!item) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Item not found</p>
<Button variant="outline" onClick={() => router.push('/admin')} className="mt-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Moderation Queue
</Button>
</div>
);
}

return (
<div className="container mx-auto py-8 px-4 max-w-4xl">
<Button variant="ghost" onClick={() => router.push('/admin')} className="mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Queue
</Button>

<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>{item.title}</CardTitle>
<CardDescription>Submitted by {item.submittedBy} on {new Date(item.submittedAt).toLocaleString()}</CardDescription>
</div>
<Badge variant={item.status === 'pending' ? 'outline' : item.status === 'approved' ? 'default' : 'destructive'}>
{item.status || 'Pending'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg p-4 bg-muted/50">
<h4 className="font-medium mb-2">Details</h4>
<pre className="text-sm whitespace-pre-wrap">
{JSON.stringify(item.details, null, 2)}
</pre>
</div>

{item.status === 'pending' && (
<div className="flex gap-4 pt-4 border-t">
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => handleModerate('approve')}
disabled={processing}
>
{processing ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4 mr-2" />}
Approve
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={() => handleModerate('reject')}
disabled={processing}
>
{processing ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4 mr-2" />}
Reject
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
189 changes: 189 additions & 0 deletions frontend/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>;
};

export default function AdminModerationPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState('grants');
const [items, setItems] = useState<PendingItem[]>([]);
const [loading, setLoading] = useState(true);
const [processingId, setProcessingId] = useState<string | null>(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 <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300">Pending</Badge>;
case 'approved': return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-300">Approved</Badge>;
case 'rejected': return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-300">Rejected</Badge>;
default: return <Badge variant="outline">{status}</Badge>;
}
};

if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}

return (
<div className="container mx-auto py-8 px-4 max-w-7xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Moderation Queue</h1>
<p className="text-muted-foreground">Review and manage pending grant submissions, updates, and user reports</p>
</div>
<div className="text-sm text-muted-foreground">
{items.length} pending items
</div>
</div>

<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="grants">Grants ({items.filter(i => i.type === 'grant').length})</TabsTrigger>
<TabsTrigger value="updates">Updates ({items.filter(i => i.type === 'update').length})</TabsTrigger>
<TabsTrigger value="reports">Reports ({items.filter(i => i.type === 'report').length})</TabsTrigger>
</TabsList>

<TabsContent value={activeTab}>
<Card>
<CardHeader>
<CardTitle>Pending {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}</CardTitle>
<CardDescription>Review and take action on pending submissions</CardDescription>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Clock className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No pending items</p>
<p className="text-sm">All {activeTab} have been reviewed</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Submitted By</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item.submittedBy}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(item.submittedAt).toLocaleDateString()}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/admin/moderation/${item.id}`)}
>
<Eye className="h-4 w-4 mr-1" />
View
</Button>
<Button
size="sm"
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={() => handleModerate(item.id, 'approve')}
disabled={processingId === item.id}
>
{processingId === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<CheckCircle className="h-4 w-4 mr-1" />
Approve
</>
)}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleModerate(item.id, 'reject')}
disabled={processingId === item.id}
>
{processingId === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<XCircle className="h-4 w-4 mr-1" />
Reject
</>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
43 changes: 43 additions & 0 deletions frontend/src/app/api/admin/moderation/item/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';

// Reuse the same mock data as the main moderation route
const mockItems: Record<string, any[]> = {
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 }
);
}
Loading