fediversion/frontend/app/admin/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

413 lines
18 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import {
Users, Music2, MapPin, Calendar, BarChart3,
Shield, ShieldCheck, Search, Check, X, Edit
} from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
interface PlatformStats {
total_users: number
verified_users: number
total_shows: number
total_songs: number
total_venues: number
total_ratings: number
total_reviews: number
total_comments: number
}
interface UserItem {
id: number
email: string
username: string | null
role: string
is_active: boolean
email_verified: boolean
}
export default function AdminPage() {
const { user, token, loading: authLoading } = useAuth()
const router = useRouter()
const [stats, setStats] = useState<PlatformStats | null>(null)
const [users, setUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingUser, setEditingUser] = useState<UserItem | null>(null)
useEffect(() => {
// Wait for auth to finish loading before checking
if (authLoading) return
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchData()
}, [user, router, authLoading])
const fetchData = async () => {
if (!token) return
try {
const [statsRes, usersRes] = await Promise.all([
fetch(`${getApiUrl()}/admin/stats`, {
headers: { Authorization: `Bearer ${token}` }
}),
fetch(`${getApiUrl()}/admin/users`, {
headers: { Authorization: `Bearer ${token}` }
})
])
if (statsRes.ok) setStats(await statsRes.json())
if (usersRes.ok) setUsers(await usersRes.json())
} catch (e) {
console.error("Failed to fetch admin data", e)
} finally {
setLoading(false)
}
}
const updateUser = async (userId: number, updates: Partial<UserItem>) => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/admin/users/${userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(updates)
})
if (res.ok) {
fetchData()
setEditingUser(null)
}
} catch (e) {
console.error("Failed to update user", e)
}
}
const filteredUsers = users.filter(u =>
u.email.toLowerCase().includes(search.toLowerCase()) ||
(u.username && u.username.toLowerCase().includes(search.toLowerCase()))
)
if (loading) {
return (
<div className="container py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-muted rounded" />)}
</div>
</div>
</div>
)
}
if (!user || user.role !== "admin") {
return null
}
return (
<div className="container py-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Shield className="h-8 w-8" />
Admin Dashboard
</h1>
</div>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-500" />
<div>
<p className="text-2xl font-bold">{stats.total_users}</p>
<p className="text-sm text-muted-foreground">Total Users</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{stats.verified_users}</p>
<p className="text-sm text-muted-foreground">Verified</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-purple-500" />
<div>
<p className="text-2xl font-bold">{stats.total_shows}</p>
<p className="text-sm text-muted-foreground">Shows</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Music2 className="h-5 w-5 text-orange-500" />
<div>
<p className="text-2xl font-bold">{stats.total_songs}</p>
<p className="text-sm text-muted-foreground">Songs</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
<Tabs defaultValue="users">
<TabsList>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">User</th>
<th className="text-left p-3 font-medium">Role</th>
<th className="text-left p-3 font-medium">Status</th>
<th className="text-left p-3 font-medium">Verified</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(u => (
<tr key={u.id} className="border-t">
<td className="p-3">
<div>
<p className="font-medium">{u.username || "No username"}</p>
<p className="text-sm text-muted-foreground">{u.email}</p>
</div>
</td>
<td className="p-3">
<Badge variant={u.role === "admin" ? "default" : u.role === "moderator" ? "secondary" : "outline"}>
{u.role}
</Badge>
</td>
<td className="p-3">
{u.is_active ? (
<Badge variant="outline" className="text-green-600 border-green-600">Active</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600">Banned</Badge>
)}
</td>
<td className="p-3">
{u.email_verified ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingUser(u)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="content" className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Shows
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_shows || 0} shows in database
</p>
<Button variant="outline" size="sm">
Manage Shows
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Music2 className="h-5 w-5" />
Songs
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_songs || 0} songs in database
</p>
<Button variant="outline" size="sm">
Manage Songs
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Venues
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_venues || 0} venues in database
</p>
<Button variant="outline" size="sm">
Manage Venues
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm">
<p>{stats?.total_ratings || 0} ratings</p>
<p>{stats?.total_reviews || 0} reviews</p>
<p>{stats?.total_comments || 0} comments</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
{editingUser && (
<div className="space-y-4 py-4">
<div>
<p className="font-medium">{editingUser.email}</p>
<p className="text-sm text-muted-foreground">{editingUser.username || "No username"}</p>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select
value={editingUser.role}
onValueChange={(value) => setEditingUser({ ...editingUser, role: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label>Account Active</Label>
<Button
variant={editingUser.is_active ? "default" : "destructive"}
size="sm"
onClick={() => setEditingUser({ ...editingUser, is_active: !editingUser.is_active })}
>
{editingUser.is_active ? "Active" : "Banned"}
</Button>
</div>
<div className="flex items-center justify-between">
<Label>Email Verified</Label>
<Button
variant={editingUser.email_verified ? "default" : "outline"}
size="sm"
onClick={() => setEditingUser({ ...editingUser, email_verified: !editingUser.email_verified })}
>
{editingUser.email_verified ? "Verified" : "Unverified"}
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingUser(null)}>
Cancel
</Button>
<Button onClick={() => editingUser && updateUser(editingUser.id, {
role: editingUser.role,
is_active: editingUser.is_active,
email_verified: editingUser.email_verified
})}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}