Add admin dashboard page with usage stats, user breakdown, admin-only nav
This commit is contained in:
@@ -12,6 +12,7 @@ import Recommendations from './pages/Recommendations'
|
|||||||
import Billing from './pages/Billing'
|
import Billing from './pages/Billing'
|
||||||
import TasteProfilePage from './pages/TasteProfilePage'
|
import TasteProfilePage from './pages/TasteProfilePage'
|
||||||
import BandcampDiscover from './pages/BandcampDiscover'
|
import BandcampDiscover from './pages/BandcampDiscover'
|
||||||
|
import Admin from './pages/Admin'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -113,6 +114,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Admin />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
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'
|
import { useAuth } from '../lib/auth'
|
||||||
|
|
||||||
const navItems = [
|
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
|
||||||
|
|
||||||
|
const baseNavItems = [
|
||||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
|
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
|
||||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||||
@@ -17,6 +19,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const navItems = user?.email === ADMIN_EMAIL
|
||||||
|
? [...baseNavItems, { path: '/admin', label: 'Admin', icon: Shield }]
|
||||||
|
: baseNavItems
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|||||||
@@ -299,4 +299,15 @@ export const fixPlaylist = (playlistId: string) =>
|
|||||||
export const getTasteProfile = () =>
|
export const getTasteProfile = () =>
|
||||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
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
|
export default api
|
||||||
|
|||||||
186
frontend/src/pages/Admin.tsx
Normal file
186
frontend/src/pages/Admin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user