feat: Add Admin Panel (Phase 2)
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
This commit is contained in:
parent
9af0bc4b96
commit
c16fe559e0
3 changed files with 838 additions and 23 deletions
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import FastAPI
|
||||
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats
|
||||
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ app.include_router(notifications.router)
|
|||
app.include_router(feed.router)
|
||||
app.include_router(leaderboards.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
|
|
|||
434
backend/routers/admin.py
Normal file
434
backend/routers/admin.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
"""
|
||||
Admin Router - Protected endpoints for admin users only.
|
||||
User management, content CRUD, platform stats.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select, func
|
||||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance
|
||||
from dependencies import RoleChecker
|
||||
from auth import get_password_hash
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# Only admins can access these endpoints
|
||||
allow_admin = RoleChecker(["admin"])
|
||||
|
||||
|
||||
# ============ SCHEMAS ============
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
email_verified: Optional[bool] = None
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ShowCreate(BaseModel):
|
||||
date: datetime
|
||||
vertical_id: int
|
||||
venue_id: Optional[int] = None
|
||||
tour_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
bandcamp_link: Optional[str] = None
|
||||
nugs_link: Optional[str] = None
|
||||
youtube_link: Optional[str] = None
|
||||
|
||||
class ShowUpdate(BaseModel):
|
||||
date: Optional[datetime] = None
|
||||
venue_id: Optional[int] = None
|
||||
tour_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
bandcamp_link: Optional[str] = None
|
||||
nugs_link: Optional[str] = None
|
||||
youtube_link: Optional[str] = None
|
||||
|
||||
class SongCreate(BaseModel):
|
||||
title: str
|
||||
vertical_id: int
|
||||
original_artist: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
youtube_link: Optional[str] = None
|
||||
|
||||
class SongUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
original_artist: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
youtube_link: Optional[str] = None
|
||||
|
||||
class VenueCreate(BaseModel):
|
||||
name: str
|
||||
city: str
|
||||
state: Optional[str] = None
|
||||
country: str = "USA"
|
||||
capacity: Optional[int] = None
|
||||
|
||||
class VenueUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
|
||||
class TourCreate(BaseModel):
|
||||
name: str
|
||||
vertical_id: int
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
class TourUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
class PlatformStats(BaseModel):
|
||||
total_users: int
|
||||
verified_users: int
|
||||
total_shows: int
|
||||
total_songs: int
|
||||
total_venues: int
|
||||
total_ratings: int
|
||||
total_reviews: int
|
||||
total_comments: int
|
||||
|
||||
|
||||
# ============ STATS ============
|
||||
|
||||
@router.get("/stats", response_model=PlatformStats)
|
||||
def get_platform_stats(
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Get platform-wide statistics"""
|
||||
return PlatformStats(
|
||||
total_users=session.exec(select(func.count(User.id))).one(),
|
||||
verified_users=session.exec(select(func.count(User.id)).where(User.email_verified == True)).one(),
|
||||
total_shows=session.exec(select(func.count(Show.id))).one(),
|
||||
total_songs=session.exec(select(func.count(Song.id))).one(),
|
||||
total_venues=session.exec(select(func.count(Venue.id))).one(),
|
||||
total_ratings=session.exec(select(func.count(Rating.id))).one(),
|
||||
total_reviews=session.exec(select(func.count(Review.id))).one(),
|
||||
total_comments=session.exec(select(func.count(Comment.id))).one(),
|
||||
)
|
||||
|
||||
|
||||
# ============ USERS ============
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""List all users with optional filtering"""
|
||||
query = select(User)
|
||||
|
||||
if search:
|
||||
query = query.where(User.email.contains(search))
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
users = session.exec(query).all()
|
||||
|
||||
# Get profiles for usernames
|
||||
result = []
|
||||
for user in users:
|
||||
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": profile.username if profile else None,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email_verified": user.email_verified,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
def get_user(
|
||||
user_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Get user details with activity stats"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": profile.username if profile else None,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email_verified": user.email_verified,
|
||||
"bio": user.bio,
|
||||
"stats": {
|
||||
"ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(),
|
||||
"reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(),
|
||||
"comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(),
|
||||
"attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
update: UserUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(allow_admin)
|
||||
):
|
||||
"""Update user role, status, or verification"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent admin from demoting themselves
|
||||
if user.id == admin.id and update.role and update.role != "admin":
|
||||
raise HTTPException(status_code=400, detail="Cannot demote yourself")
|
||||
|
||||
if update.role is not None:
|
||||
user.role = update.role
|
||||
if update.is_active is not None:
|
||||
user.is_active = update.is_active
|
||||
if update.email_verified is not None:
|
||||
user.email_verified = update.email_verified
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
return {"message": "User updated", "user_id": user.id}
|
||||
|
||||
|
||||
# ============ SHOWS ============
|
||||
|
||||
@router.post("/shows")
|
||||
def create_show(
|
||||
show_data: ShowCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Create a new show"""
|
||||
show = Show(**show_data.model_dump())
|
||||
session.add(show)
|
||||
session.commit()
|
||||
session.refresh(show)
|
||||
return show
|
||||
|
||||
|
||||
@router.patch("/shows/{show_id}")
|
||||
def update_show(
|
||||
show_id: int,
|
||||
update: ShowUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Update show details"""
|
||||
show = session.get(Show, show_id)
|
||||
if not show:
|
||||
raise HTTPException(status_code=404, detail="Show not found")
|
||||
|
||||
for key, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(show, key, value)
|
||||
|
||||
session.add(show)
|
||||
session.commit()
|
||||
session.refresh(show)
|
||||
return show
|
||||
|
||||
|
||||
@router.delete("/shows/{show_id}")
|
||||
def delete_show(
|
||||
show_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Delete a show"""
|
||||
show = session.get(Show, show_id)
|
||||
if not show:
|
||||
raise HTTPException(status_code=404, detail="Show not found")
|
||||
|
||||
session.delete(show)
|
||||
session.commit()
|
||||
return {"message": "Show deleted", "show_id": show_id}
|
||||
|
||||
|
||||
# ============ SONGS ============
|
||||
|
||||
@router.post("/songs")
|
||||
def create_song(
|
||||
song_data: SongCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Create a new song"""
|
||||
song = Song(**song_data.model_dump())
|
||||
session.add(song)
|
||||
session.commit()
|
||||
session.refresh(song)
|
||||
return song
|
||||
|
||||
|
||||
@router.patch("/songs/{song_id}")
|
||||
def update_song(
|
||||
song_id: int,
|
||||
update: SongUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Update song details"""
|
||||
song = session.get(Song, song_id)
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
for key, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(song, key, value)
|
||||
|
||||
session.add(song)
|
||||
session.commit()
|
||||
session.refresh(song)
|
||||
return song
|
||||
|
||||
|
||||
@router.delete("/songs/{song_id}")
|
||||
def delete_song(
|
||||
song_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Delete a song"""
|
||||
song = session.get(Song, song_id)
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
session.delete(song)
|
||||
session.commit()
|
||||
return {"message": "Song deleted", "song_id": song_id}
|
||||
|
||||
|
||||
# ============ VENUES ============
|
||||
|
||||
@router.post("/venues")
|
||||
def create_venue(
|
||||
venue_data: VenueCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Create a new venue"""
|
||||
venue = Venue(**venue_data.model_dump())
|
||||
session.add(venue)
|
||||
session.commit()
|
||||
session.refresh(venue)
|
||||
return venue
|
||||
|
||||
|
||||
@router.patch("/venues/{venue_id}")
|
||||
def update_venue(
|
||||
venue_id: int,
|
||||
update: VenueUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Update venue details"""
|
||||
venue = session.get(Venue, venue_id)
|
||||
if not venue:
|
||||
raise HTTPException(status_code=404, detail="Venue not found")
|
||||
|
||||
for key, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(venue, key, value)
|
||||
|
||||
session.add(venue)
|
||||
session.commit()
|
||||
session.refresh(venue)
|
||||
return venue
|
||||
|
||||
|
||||
@router.delete("/venues/{venue_id}")
|
||||
def delete_venue(
|
||||
venue_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Delete a venue"""
|
||||
venue = session.get(Venue, venue_id)
|
||||
if not venue:
|
||||
raise HTTPException(status_code=404, detail="Venue not found")
|
||||
|
||||
session.delete(venue)
|
||||
session.commit()
|
||||
return {"message": "Venue deleted", "venue_id": venue_id}
|
||||
|
||||
|
||||
# ============ TOURS ============
|
||||
|
||||
@router.post("/tours")
|
||||
def create_tour(
|
||||
tour_data: TourCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Create a new tour"""
|
||||
tour = Tour(**tour_data.model_dump())
|
||||
session.add(tour)
|
||||
session.commit()
|
||||
session.refresh(tour)
|
||||
return tour
|
||||
|
||||
|
||||
@router.patch("/tours/{tour_id}")
|
||||
def update_tour(
|
||||
tour_id: int,
|
||||
update: TourUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Update tour details"""
|
||||
tour = session.get(Tour, tour_id)
|
||||
if not tour:
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
for key, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(tour, key, value)
|
||||
|
||||
session.add(tour)
|
||||
session.commit()
|
||||
session.refresh(tour)
|
||||
return tour
|
||||
|
||||
|
||||
@router.delete("/tours/{tour_id}")
|
||||
def delete_tour(
|
||||
tour_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(allow_admin)
|
||||
):
|
||||
"""Delete a tour"""
|
||||
tour = session.get(Tour, tour_id)
|
||||
if not tour:
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
session.delete(tour)
|
||||
session.commit()
|
||||
return {"message": "Tour deleted", "tour_id": tour_id}
|
||||
|
|
@ -1,30 +1,410 @@
|
|||
"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"
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Pending Nicknames</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">--</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Pending Reports</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">--</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
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>
|
||||
<p className="text-muted-foreground">Select a category from the sidebar to manage content.</p>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue