elmeg-demo/frontend/app/mod/page.tsx
fullsizemalt ad2e6a107e feat: Enhance Mod Panel (Phase 3)
Backend:
- User lookup by email/username with activity stats
- Ban/unban endpoints with role protection
- Bulk approve/reject nicknames
- Bulk resolve/dismiss reports
- Queue stats endpoint

Frontend:
- Stats cards (pending items, ban count)
- User Lookup tab with search
- User profile with activity stats
- Ban dialog with duration selector
- Bulk selection checkboxes on queues
2025-12-21 14:04:33 -08:00

594 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Check, X, ShieldAlert, Search, Ban, UserCheck, CheckCircle } from "lucide-react"
import { useEffect, useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getApiUrl } from "@/lib/api-config"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
interface PendingNickname {
id: number
performance_id: number
nickname: string
suggested_by: number
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
}
interface QueueStats {
pending_nicknames: number
pending_reports: number
total_bans: number
}
interface UserLookup {
id: number
email: string
username: string | null
role: string
is_active: boolean
email_verified: boolean
stats: {
ratings: number
reviews: number
comments: number
attendances: number
reports_submitted: number
}
}
export default function ModDashboardPage() {
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
const [pendingReports, setPendingReports] = useState<PendingReport[]>([])
const [stats, setStats] = useState<QueueStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
// User lookup
const [lookupQuery, setLookupQuery] = useState("")
const [lookupUser, setLookupUser] = useState<UserLookup | null>(null)
const [lookupLoading, setLookupLoading] = useState(false)
// Ban dialog
const [banDialogOpen, setBanDialogOpen] = useState(false)
const [banDuration, setBanDuration] = useState("24")
const [banReason, setBanReason] = useState("")
// Bulk selection
const [selectedNicknames, setSelectedNicknames] = useState<number[]>([])
const [selectedReports, setSelectedReports] = useState<number[]>([])
useEffect(() => {
fetchQueue()
}, [])
const fetchQueue = async () => {
const token = localStorage.getItem("token")
if (!token) {
setError("Not authenticated")
setLoading(false)
return
}
try {
const [nicknamesRes, reportsRes, statsRes] = await Promise.all([
fetch(`${getApiUrl()}/moderation/queue/nicknames`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${getApiUrl()}/moderation/queue/reports`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${getApiUrl()}/moderation/queue/stats`, { headers: { Authorization: `Bearer ${token}` } })
])
if (nicknamesRes.status === 403 || reportsRes.status === 403) {
setError("Access denied: Moderators only")
setLoading(false)
return
}
if (!nicknamesRes.ok || !reportsRes.ok) throw new Error("Failed to fetch queue")
setPendingNicknames(await nicknamesRes.json())
setPendingReports(await reportsRes.json())
if (statsRes.ok) setStats(await statsRes.json())
} catch (err) {
console.error(err)
setError("Failed to load moderation queue")
} finally {
setLoading(false)
}
}
const handleUserLookup = async () => {
if (!lookupQuery.trim()) return
const token = localStorage.getItem("token")
if (!token) return
setLookupLoading(true)
try {
const res = await fetch(`${getApiUrl()}/moderation/users/lookup?query=${encodeURIComponent(lookupQuery)}`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
setLookupUser(await res.json())
} else {
setLookupUser(null)
alert("User not found")
}
} catch (e) {
console.error(e)
} finally {
setLookupLoading(false)
}
}
const handleBanUser = async () => {
if (!lookupUser) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/users/ban`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
user_id: lookupUser.id,
duration_hours: parseInt(banDuration),
reason: banReason
})
})
if (res.ok) {
setLookupUser({ ...lookupUser, is_active: false })
setBanDialogOpen(false)
setBanReason("")
}
} catch (e) {
console.error(e)
}
}
const handleUnbanUser = async () => {
if (!lookupUser) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/users/${lookupUser.id}/unban`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
setLookupUser({ ...lookupUser, is_active: true })
}
} catch (e) {
console.error(e)
}
}
const handleNicknameAction = async (id: number, action: "approve" | "reject") => {
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error(`Failed to ${action}`)
setPendingNicknames(prev => prev.filter(n => n.id !== id))
} catch (err) {
console.error(err)
alert(`Error: ${err}`)
}
}
const handleReportAction = async (id: number, action: "resolve" | "dismiss") => {
const token = localStorage.getItem("token")
if (!token) return
try {
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}`)
}
}
const handleBulkNicknames = async (action: "approve" | "reject") => {
if (selectedNicknames.length === 0) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/nicknames/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedNicknames, action })
})
if (res.ok) {
setPendingNicknames(prev => prev.filter(n => !selectedNicknames.includes(n.id)))
setSelectedNicknames([])
}
} catch (e) {
console.error(e)
}
}
const handleBulkReports = async (action: "resolve" | "dismiss") => {
if (selectedReports.length === 0) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/reports/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedReports, action })
})
if (res.ok) {
setPendingReports(prev => prev.filter(r => !selectedReports.includes(r.id)))
setSelectedReports([])
}
} catch (e) {
console.error(e)
}
}
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>
return (
<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">
<ShieldAlert className="h-8 w-8 text-primary" />
Moderator Dashboard
</h1>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.pending_nicknames}</p>
<p className="text-sm text-muted-foreground">Pending Nicknames</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.pending_reports}</p>
<p className="text-sm text-muted-foreground">Pending Reports</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.total_bans}</p>
<p className="text-sm text-muted-foreground">Banned Users</p>
</CardContent>
</Card>
</div>
)}
<Tabs defaultValue="reports">
<TabsList>
<TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger>
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
<TabsTrigger value="users">User Lookup</TabsTrigger>
</TabsList>
<TabsContent value="reports">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>User Reports</CardTitle>
{selectedReports.length > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={() => handleBulkReports("resolve")}>
Resolve All ({selectedReports.length})
</Button>
<Button size="sm" variant="outline" onClick={() => handleBulkReports("dismiss")}>
Dismiss All
</Button>
</div>
)}
</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 className="flex items-start gap-3">
<Checkbox
checked={selectedReports.includes(report.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedReports([...selectedReports, report.id])
} else {
setSelectedReports(selectedReports.filter(id => id !== report.id))
}
}}
/>
<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">&quot;{report.details}&quot;</p>
)}
<p className="text-xs text-muted-foreground">
Reported by User #{report.user_id} {new Date(report.created_at).toLocaleString()}
</p>
</div>
</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
</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">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending Nicknames</CardTitle>
{selectedNicknames.length > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="default" onClick={() => handleBulkNicknames("approve")}>
<CheckCircle className="h-4 w-4 mr-1" /> Approve All ({selectedNicknames.length})
</Button>
<Button size="sm" variant="outline" onClick={() => handleBulkNicknames("reject")}>
Reject All
</Button>
</div>
)}
</CardHeader>
<CardContent>
{pendingNicknames.length === 0 ? (
<p className="text-muted-foreground py-8 text-center">No pending nicknames.</p>
) : (
<div className="space-y-4">
{pendingNicknames.map((item) => (
<div key={item.id} className="flex items-center justify-between border p-4 rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={selectedNicknames.includes(item.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedNicknames([...selectedNicknames, item.id])
} else {
setSelectedNicknames(selectedNicknames.filter(id => id !== item.id))
}
}}
/>
<div>
<p className="font-bold text-lg">&quot;{item.nickname}&quot;</p>
<p className="text-sm text-muted-foreground">
Performance #{item.performance_id} User #{item.suggested_by}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={() => handleNicknameAction(item.id, "approve")}
>
<Check className="h-4 w-4 mr-1" /> Approve
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleNicknameAction(item.id, "reject")}
>
<X className="h-4 w-4 mr-1" /> Reject
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle>User Lookup</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by email or username..."
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
className="pl-9"
onKeyDown={(e) => e.key === "Enter" && handleUserLookup()}
/>
</div>
<Button onClick={handleUserLookup} disabled={lookupLoading}>
{lookupLoading ? "Searching..." : "Search"}
</Button>
</div>
{lookupUser && (
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="font-bold text-lg">{lookupUser.username || "No username"}</p>
<p className="text-sm text-muted-foreground">{lookupUser.email}</p>
<div className="flex gap-2 mt-2">
<Badge>{lookupUser.role}</Badge>
{lookupUser.is_active ? (
<Badge variant="outline" className="text-green-600 border-green-600">Active</Badge>
) : (
<Badge variant="destructive">Banned</Badge>
)}
{lookupUser.email_verified && (
<Badge variant="outline">Verified</Badge>
)}
</div>
</div>
{lookupUser.is_active ? (
<Button
variant="destructive"
size="sm"
onClick={() => setBanDialogOpen(true)}
>
<Ban className="h-4 w-4 mr-1" /> Ban User
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleUnbanUser}
>
<UserCheck className="h-4 w-4 mr-1" /> Unban
</Button>
)}
</div>
<div className="grid grid-cols-5 gap-4 text-center">
<div>
<p className="text-2xl font-bold">{lookupUser.stats.ratings}</p>
<p className="text-xs text-muted-foreground">Ratings</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.reviews}</p>
<p className="text-xs text-muted-foreground">Reviews</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.comments}</p>
<p className="text-xs text-muted-foreground">Comments</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.attendances}</p>
<p className="text-xs text-muted-foreground">Shows</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.reports_submitted}</p>
<p className="text-xs text-muted-foreground">Reports</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Ban Dialog */}
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
Banning <strong>{lookupUser?.email}</strong>
</p>
<div className="space-y-2">
<Label>Ban Duration</Label>
<Select value={banDuration} onValueChange={setBanDuration}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 hour</SelectItem>
<SelectItem value="24">24 hours</SelectItem>
<SelectItem value="168">7 days</SelectItem>
<SelectItem value="720">30 days</SelectItem>
<SelectItem value="0">Permanent</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Textarea
placeholder="Reason for ban..."
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBanDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleBanUser}>
<Ban className="h-4 w-4 mr-1" /> Confirm Ban
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}