fediversion/frontend/app/mod/page.tsx
fullsizemalt b4cddf41ea feat: Initialize Fediversion multi-band platform
- Fork elmeg-demo codebase for multi-band support
- Add data importer infrastructure with base class
- Create band-specific importers:
  - phish.py: Phish.net API v5
  - grateful_dead.py: Grateful Stats API
  - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm)
- Add spec-kit configuration for Gemini
- Update README with supported bands and architecture
2025-12-28 12:39:28 -08:00

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">&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>
)
}