diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e17070c..aec93fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + } + /> } /> ) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f4863b5..3abdf4e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0138899..21226fd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -299,4 +299,15 @@ export const fixPlaylist = (playlistId: string) => export const getTasteProfile = () => api.get('/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('/admin/stats').then((r) => r.data) + export default api diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..d7ea299 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+ ) + } + + if (error || !isAdmin) { + return ( +
+ +

403 Forbidden

+

{error || 'Access denied. Admin privileges required.'}

+
+ ) + } + + 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 ( +
+ {/* Header */} +
+
+ +

Admin Dashboard

+
+
+ + {/* Top row - 3 stat cards */} +
+
+
+
+ +
+

Users

+
+

{stats.users.total}

+
+ {stats.users.pro} Pro + {stats.users.free} Free +
+
+ +
+
+
+ +
+

Playlists

+
+

{stats.playlists.total}

+
+ {stats.playlists.total_tracks} total tracks +
+
+ +
+
+
+ +
+

Recommendations

+
+

{stats.recommendations.total}

+
+ {stats.recommendations.today} today + {stats.recommendations.this_week} this week + {stats.recommendations.this_month} this month +
+
+
+ + {/* Middle row - 2 stat cards */} +
+
+
+
+ +
+

Saved Recommendations

+
+

{stats.recommendations.saved}

+
+ +
+
+
+ +
+

Disliked Recommendations

+
+

{stats.recommendations.disliked}

+
+
+ + {/* User breakdown table */} +
+
+

User Breakdown

+
+
+ + + + + + + + + + + + {sortedUsers.map((u, i) => ( + + + + + + + + ))} + +
NameEmailStatusJoinedRecommendations
{u.name}{u.email} + {u.is_pro ? ( + + Pro + + ) : ( + + Free + + )} + {formatDate(u.created_at)}{u.recommendation_count}
+
+
+
+ ) +}