Backend: - admin.py router with user management CRUD - Platform stats endpoint - Shows/Songs/Venues/Tours CRUD - Protected by RoleChecker (admin only) Frontend: - /admin dashboard with stats cards - Users tab with search and edit dialog - Content tab with entity counts - Role/ban/verification management
410 lines
18 KiB
TypeScript
410 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 } = 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(() => {
|
|
if (!user) {
|
|
router.push("/login")
|
|
return
|
|
}
|
|
if (user.role !== "admin") {
|
|
router.push("/")
|
|
return
|
|
}
|
|
fetchData()
|
|
}, [user, router])
|
|
|
|
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>
|
|
)
|
|
}
|