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 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
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ app.include_router(notifications.router)
|
||||||
app.include_router(feed.router)
|
app.include_router(feed.router)
|
||||||
app.include_router(leaderboards.router)
|
app.include_router(leaderboards.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
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"
|
"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 { 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="container py-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="h-8 bg-muted rounded w-48" />
|
||||||
<Card>
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-muted rounded" />)}
|
||||||
<CardTitle className="text-sm font-medium">Pending Nicknames</CardTitle>
|
</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">Select a category from the sidebar to manage content.</p>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue