feat: add Admin Interface for Artists management
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 12:34:43 -08:00
parent a076336d5e
commit d276cdbd76
4 changed files with 311 additions and 2 deletions

View file

@ -51,3 +51,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session: Session
if user is None: if user is None:
raise credentials_exception raise credentials_exception
return user return user
async def get_current_superuser(current_user: User = Depends(get_current_user)):
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return current_user

View file

@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func from sqlmodel import Session, select, func
from pydantic import BaseModel from pydantic import BaseModel
from database import get_session from database import get_session
from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance, Artist
from dependencies import RoleChecker from dependencies import RoleChecker
from auth import get_password_hash from auth import get_password_hash
@ -88,11 +88,18 @@ class TourCreate(BaseModel):
start_date: Optional[datetime] = None start_date: Optional[datetime] = None
end_date: Optional[datetime] = None end_date: Optional[datetime] = None
class TourUpdate(BaseModel): class TourUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
start_date: Optional[datetime] = None start_date: Optional[datetime] = None
end_date: Optional[datetime] = None end_date: Optional[datetime] = None
class ArtistUpdate(BaseModel):
bio: Optional[str] = None
image_url: Optional[str] = None
notes: Optional[str] = None
class PlatformStats(BaseModel): class PlatformStats(BaseModel):
total_users: int total_users: int
verified_users: int verified_users: int
@ -434,6 +441,59 @@ def delete_tour(
return {"message": "Tour deleted", "tour_id": tour_id} return {"message": "Tour deleted", "tour_id": tour_id}
# ============ ARTISTS ============
@router.get("/artists")
def list_artists(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""List all artists with optional search"""
query = select(Artist)
if search:
query = query.where(Artist.name.icontains(search))
query = query.order_by(Artist.name).offset(skip).limit(limit)
artists = session.exec(query).all()
return [
{
"id": a.id,
"name": a.name,
"slug": a.slug,
"bio": a.bio,
"image_url": a.image_url,
"notes": a.notes,
}
for a in artists
]
@router.patch("/artists/{artist_id}")
def update_artist(
artist_id: int,
update: ArtistUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update artist details (bio, image, notes)"""
artist = session.get(Artist, artist_id)
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(artist, key, value)
session.add(artist)
session.commit()
session.refresh(artist)
return artist
# ============ PERFORMANCES ============ # ============ PERFORMANCES ============
from models import Performance from models import Performance

View file

@ -0,0 +1,236 @@
"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 { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Save, X, Mic2 } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
interface Artist {
id: number
name: string
slug: string
bio: string | null
image_url: string | null
notes: string | null
}
export default function AdminArtistsPage() {
const { user, token } = useAuth()
const router = useRouter()
const [artists, setArtists] = useState<Artist[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingArtist, setEditingArtist] = useState<Artist | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchArtists()
}, [user, router])
const fetchArtists = async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/admin/artists?limit=100`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) setArtists(await res.json())
} catch (e) {
console.error("Failed to fetch artists", e)
} finally {
setLoading(false)
}
}
const updateArtist = async () => {
if (!token || !editingArtist) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/admin/artists/${editingArtist.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
bio: editingArtist.bio,
image_url: editingArtist.image_url,
notes: editingArtist.notes
})
})
if (res.ok) {
fetchArtists()
setEditingArtist(null)
}
} catch (e) {
console.error("Failed to update artist", e)
} finally {
setSaving(false)
}
}
const filteredArtists = artists.filter(a =>
a.name.toLowerCase().includes(search.toLowerCase())
)
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mic2 className="h-6 w-6" />
Artist Management
</h2>
</div>
<div className="relative 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 artists..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Artist</th>
<th className="text-left p-3 font-medium">Bio</th>
<th className="text-left p-3 font-medium">Image</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredArtists.map(artist => (
<tr key={artist.id} className="border-t">
<td className="p-3">
<div>
<p className="font-medium">{artist.name}</p>
<p className="text-sm text-muted-foreground">{artist.slug}</p>
</div>
</td>
<td className="p-3">
{artist.bio ? (
<Badge variant="outline" className="text-green-600 border-green-600">Has Bio</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600">No Bio</Badge>
)}
</td>
<td className="p-3">
{artist.image_url ? (
<Badge variant="outline" className="text-green-600 border-green-600">Has Image</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600">No Image</Badge>
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingArtist(artist)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredArtists.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No artists found
</div>
)}
</CardContent>
</Card>
<Dialog open={!!editingArtist} onOpenChange={() => setEditingArtist(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit Artist: {editingArtist?.name}</DialogTitle>
</DialogHeader>
{editingArtist && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Bio</Label>
<Textarea
placeholder="Artist biography..."
value={editingArtist.bio || ""}
onChange={(e) => setEditingArtist({ ...editingArtist, bio: e.target.value })}
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Image URL</Label>
<Input
placeholder="https://..."
value={editingArtist.image_url || ""}
onChange={(e) => setEditingArtist({ ...editingArtist, image_url: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Notes (internal)</Label>
<Textarea
placeholder="Internal notes..."
value={editingArtist.notes || ""}
onChange={(e) => setEditingArtist({ ...editingArtist, notes: e.target.value })}
rows={2}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingArtist(null)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={updateArtist} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -4,7 +4,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { LayoutDashboard, MessageSquare, ShieldAlert, Users } from "lucide-react" import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2 } from "lucide-react"
export default function AdminLayout({ export default function AdminLayout({
children, children,
@ -19,6 +19,11 @@ export default function AdminLayout({
href: "/admin", href: "/admin",
icon: LayoutDashboard icon: LayoutDashboard
}, },
{
title: "Artists",
href: "/admin/artists",
icon: Mic2
},
{ {
title: "Nicknames", title: "Nicknames",
href: "/admin/nicknames", href: "/admin/nicknames",