Add admin dashboard page with usage stats, user breakdown, admin-only nav

This commit is contained in:
root
2026-03-31 15:43:58 -05:00
parent cc8bb0dd09
commit 40322e8861
4 changed files with 216 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing'
import TasteProfilePage from './pages/TasteProfilePage'
import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -113,6 +114,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute>
<Layout>
<Admin />
</Layout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Disc3, LayoutDashboard, Fingerprint, ListMusic, Compass, Store, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
import { Disc3, LayoutDashboard, Fingerprint, ListMusic, Compass, Store, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
import { useAuth } from '../lib/auth'
const navItems = [
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
const baseNavItems = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
@@ -17,6 +19,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const { user, logout } = useAuth()
const navItems = user?.email === ADMIN_EMAIL
? [...baseNavItems, { path: '/admin', label: 'Admin', icon: Shield }]
: baseNavItems
const location = useLocation()
const navigate = useNavigate()

View File

@@ -299,4 +299,15 @@ export const fixPlaylist = (playlistId: string) =>
export const getTasteProfile = () =>
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
// Admin
export interface AdminStats {
users: { total: number; pro: number; free: number }
playlists: { total: number; total_tracks: number }
recommendations: { total: number; today: number; this_week: number; this_month: number; saved: number; disliked: number }
user_breakdown: { id: number; name: string; email: string; is_pro: boolean; created_at: string; recommendation_count: number }[]
}
export const getAdminStats = () =>
api.get<AdminStats>('/admin/stats').then((r) => r.data)
export default api

View File

@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getAdminStats, type AdminStats } from '../lib/api'
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
export default function Admin() {
const { user } = useAuth()
const [stats, setStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const isAdmin = user?.email === ADMIN_EMAIL
useEffect(() => {
if (!isAdmin) {
setLoading(false)
setError('Access denied. Admin privileges required.')
return
}
getAdminStats()
.then(setStats)
.catch((err) => {
setError(err.response?.status === 403 ? 'Access denied. Admin privileges required.' : 'Failed to load admin stats.')
})
.finally(() => setLoading(false))
}, [isAdmin])
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<div className="w-12 h-12 border-4 border-purple border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !isAdmin) {
return (
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Shield className="w-16 h-16 text-red-400" />
<h2 className="text-2xl font-bold text-charcoal">403 Forbidden</h2>
<p className="text-charcoal-muted">{error || 'Access denied. Admin privileges required.'}</p>
</div>
)
}
if (!stats) return null
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
const sortedUsers = [...stats.user_breakdown].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div>
{/* Header */}
<div className="bg-charcoal rounded-2xl p-6 sm:p-8 mb-8">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-purple-300" />
<h1 className="text-2xl sm:text-3xl font-bold text-white">Admin Dashboard</h1>
</div>
</div>
{/* Top row - 3 stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Users className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Users</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.users.total}</p>
<div className="flex gap-4 text-sm text-charcoal-muted">
<span><span className="font-semibold text-purple">{stats.users.pro}</span> Pro</span>
<span><span className="font-semibold text-charcoal">{stats.users.free}</span> Free</span>
</div>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<ListMusic className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Playlists</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.playlists.total}</p>
<div className="text-sm text-charcoal-muted">
<span className="font-semibold text-charcoal">{stats.playlists.total_tracks}</span> total tracks
</div>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.recommendations.total}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-charcoal-muted">
<span><span className="font-semibold text-charcoal">{stats.recommendations.today}</span> today</span>
<span><span className="font-semibold text-charcoal">{stats.recommendations.this_week}</span> this week</span>
<span><span className="font-semibold text-charcoal">{stats.recommendations.this_month}</span> this month</span>
</div>
</div>
</div>
{/* Middle row - 2 stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Heart className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Saved Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.saved}</p>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<ThumbsDown className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Disliked Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.disliked}</p>
</div>
</div>
{/* User breakdown table */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
<div className="p-6 border-b border-purple-100">
<h3 className="text-lg font-bold text-charcoal">User Breakdown</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-purple-100">
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Name</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Email</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Status</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Joined</th>
<th className="text-right text-sm font-medium text-charcoal-muted px-6 py-3">Recommendations</th>
</tr>
</thead>
<tbody>
{sortedUsers.map((u, i) => (
<tr
key={u.id}
className={`border-b border-purple-50 ${i % 2 === 1 ? 'bg-purple-50/30' : ''}`}
>
<td className="px-6 py-4 text-sm font-medium text-charcoal">{u.name}</td>
<td className="px-6 py-4 text-sm text-charcoal-muted">{u.email}</td>
<td className="px-6 py-4">
{u.is_pro ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple text-white">
Pro
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600">
Free
</span>
)}
</td>
<td className="px-6 py-4 text-sm text-charcoal-muted">{formatDate(u.created_at)}</td>
<td className="px-6 py-4 text-sm font-semibold text-charcoal text-right">{u.recommendation_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}