594 lines
28 KiB
TypeScript
594 lines
28 KiB
TypeScript
"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">"{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>
|
|
<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">"{item.nickname}"</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>
|
|
)
|
|
}
|