feat(frontend): Implement Admin Dashboard for moderation
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
bd71ea27d1
commit
499a9fa352
1 changed files with 102 additions and 27 deletions
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Check, X, ShieldAlert } from "lucide-react"
|
import { Check, X, ShieldAlert, AlertTriangle } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
interface PendingNickname {
|
interface PendingNickname {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -15,8 +16,20 @@ interface PendingNickname {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingReport {
|
||||||
|
id: number
|
||||||
|
entity_type: string
|
||||||
|
entity_id: number
|
||||||
|
reason: string
|
||||||
|
details?: string
|
||||||
|
user_id: number
|
||||||
|
created_at: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModDashboardPage() {
|
export default function ModDashboardPage() {
|
||||||
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
|
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
|
||||||
|
const [pendingReports, setPendingReports] = useState<PendingReport[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
|
@ -33,20 +46,21 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiUrl()}/moderation/queue/nicknames`, {
|
const [nicknamesRes, reportsRes] = await Promise.all([
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
fetch(`${getApiUrl()}/moderation/queue/nicknames`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
})
|
fetch(`${getApiUrl()}/moderation/queue/reports`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
])
|
||||||
|
|
||||||
if (res.status === 403) {
|
if (nicknamesRes.status === 403 || reportsRes.status === 403) {
|
||||||
setError("Access denied: Moderators only")
|
setError("Access denied: Moderators only")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch queue")
|
if (!nicknamesRes.ok || !reportsRes.ok) throw new Error("Failed to fetch queue")
|
||||||
|
|
||||||
const data = await res.json()
|
setPendingNicknames(await nicknamesRes.json())
|
||||||
setPendingNicknames(data)
|
setPendingReports(await reportsRes.json())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError("Failed to load moderation queue")
|
setError("Failed to load moderation queue")
|
||||||
|
|
@ -55,13 +69,14 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = async (id: number, action: "approve" | "reject") => {
|
const handleNicknameAction = async (id: number, action: "approve" | "reject") => {
|
||||||
const token = localStorage.getItem("token")
|
const token = localStorage.getItem("token")
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Note: Updated backend uses PUT for nicknames
|
||||||
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
|
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -75,22 +90,93 @@ export default function ModDashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReportAction = async (id: number, action: "resolve" | "dismiss") => {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Note: Updated backend uses PUT for reports
|
||||||
|
const res = await fetch(`${getApiUrl()}/moderation/reports/${id}/${action}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Failed to ${action}`)
|
||||||
|
|
||||||
|
setPendingReports(prev => prev.filter(r => r.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert(`Error: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-center">Loading dashboard...</div>
|
if (loading) return <div className="p-8 text-center">Loading dashboard...</div>
|
||||||
if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
|
if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="container py-8 flex flex-col gap-6 max-w-5xl">
|
||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<ShieldAlert className="h-8 w-8 text-primary" />
|
<ShieldAlert className="h-8 w-8 text-primary" />
|
||||||
Moderator Dashboard
|
Moderator Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="nicknames">
|
<Tabs defaultValue="reports">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
<TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger>
|
||||||
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
|
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
|
||||||
<TabsTrigger value="reports">Reports (0)</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="reports">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Reports</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{pendingReports.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-8 text-center">No pending reports. Community is safe! 🛡️</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pendingReports.map(report => (
|
||||||
|
<div key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Badge variant="destructive" className="uppercase text-[10px]">
|
||||||
|
{report.reason}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-semibold">{report.entity_type} #{report.entity_id}</span>
|
||||||
|
</div>
|
||||||
|
{report.details && (
|
||||||
|
<p className="text-sm italic text-muted-foreground mb-2">"{report.details}"</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Reported by User #{report.user_id} • {new Date(report.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => handleReportAction(report.id, "resolve")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" /> Resolve (Ban/Delete)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleReportAction(report.id, "dismiss")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" /> Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="nicknames">
|
<TabsContent value="nicknames">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -98,7 +184,7 @@ export default function ModDashboardPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{pendingNicknames.length === 0 ? (
|
{pendingNicknames.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-8 text-center">No pending nicknames. Good job!</p>
|
<p className="text-muted-foreground py-8 text-center">No pending nicknames.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingNicknames.map((item) => (
|
{pendingNicknames.map((item) => (
|
||||||
|
|
@ -117,7 +203,7 @@ export default function ModDashboardPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
onClick={() => handleAction(item.id, "approve")}
|
onClick={() => handleNicknameAction(item.id, "approve")}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" /> Approve
|
<Check className="h-4 w-4 mr-1" /> Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -125,7 +211,7 @@ export default function ModDashboardPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
onClick={() => handleAction(item.id, "reject")}
|
onClick={() => handleNicknameAction(item.id, "reject")}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" /> Reject
|
<X className="h-4 w-4 mr-1" /> Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -137,17 +223,6 @@ export default function ModDashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Reports</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground">Report queue coming soon...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue