diff --git a/apps/web/src/components/admin/AdminPlatformView.tsx b/apps/web/src/components/admin/AdminPlatformView.tsx new file mode 100644 index 000000000..3cdf166be --- /dev/null +++ b/apps/web/src/components/admin/AdminPlatformView.tsx @@ -0,0 +1,454 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { EmptyState } from '../ui/empty-state'; +import { + BarChart3, + Users, + Music, + CreditCard, + FileText, + Search, + Ban, + CheckCircle, + Eye, + EyeOff, + Loader2, + Shield, +} from 'lucide-react'; +import { useToast } from '../../components/feedback/ToastProvider'; +import { + adminService, + PlatformMetrics, + AdminUserInfo, + AdminContentItem, + PaymentOverview, +} from '../../services/adminService'; +import { logger } from '@/utils/logger'; + +type PlatformTab = 'metrics' | 'users' | 'content' | 'payments'; + +export const AdminPlatformView: React.FC = () => { + const { addToast } = useToast(); + const [activeTab, setActiveTab] = useState('metrics'); + const [loading, setLoading] = useState(true); + + // Metrics + const [metrics, setMetrics] = useState(null); + + // Users + const [users, setUsers] = useState([]); + const [usersTotal, setUsersTotal] = useState(0); + const [userSearch, setUserSearch] = useState(''); + const [userRoleFilter, setUserRoleFilter] = useState(''); + + // Content + const [content, setContent] = useState([]); + const [contentTotal, setContentTotal] = useState(0); + const [contentSearch, setContentSearch] = useState(''); + const [contentType, setContentType] = useState('track'); + + // Payments + const [payments, setPayments] = useState(null); + + const loadMetrics = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.getPlatformMetrics(); + setMetrics(data); + } catch (e) { + logger.error('Error loading platform metrics', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, []); + + const loadUsers = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.searchUsers({ q: userSearch || undefined, role: userRoleFilter || undefined }); + setUsers(data.users); + setUsersTotal(data.total); + } catch (e) { + logger.error('Error loading users', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, [userSearch, userRoleFilter]); + + const loadContent = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.searchContent({ type: contentType, q: contentSearch || undefined }); + setContent(data.content); + setContentTotal(data.total); + } catch (e) { + logger.error('Error loading content', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, [contentType, contentSearch]); + + const loadPayments = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.getPaymentOverview(); + setPayments(data); + } catch (e) { + logger.error('Error loading payments', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + switch (activeTab) { + case 'metrics': loadMetrics(); break; + case 'users': loadUsers(); break; + case 'content': loadContent(); break; + case 'payments': loadPayments(); break; + } + }, [activeTab, loadMetrics, loadUsers, loadContent, loadPayments]); + + const handleSuspendUser = async (userId: string) => { + const reason = prompt('Suspension reason:'); + if (!reason) return; + try { + await adminService.suspendUser(userId, reason); + addToast('User suspended', 'success'); + loadUsers(); + } catch { addToast('Suspension failed', 'error'); } + }; + + const handleUnsuspendUser = async (userId: string) => { + try { + await adminService.unsuspendUser(userId); + addToast('User unsuspended', 'success'); + loadUsers(); + } catch { addToast('Unsuspension failed', 'error'); } + }; + + const handleHideContent = async (id: string, type: string) => { + try { + await adminService.hideContent(id, type, 'Admin action'); + addToast('Content hidden', 'success'); + loadContent(); + } catch { addToast('Hide failed', 'error'); } + }; + + const handleRestoreContent = async (id: string, type: string) => { + try { + await adminService.restoreContent(id, type); + addToast('Content restored', 'success'); + loadContent(); + } catch { addToast('Restore failed', 'error'); } + }; + + const tabs: { key: PlatformTab; label: string; icon: React.ReactNode }[] = [ + { key: 'metrics', label: 'Dashboard', icon: }, + { key: 'users', label: 'Users', icon: }, + { key: 'content', label: 'Content', icon: }, + { key: 'payments', label: 'Payments', icon: }, + ]; + + return ( +
+
+ +
+

+ PLATFORM ADMINISTRATION +

+

+ Metrics, users, content, and payments management. +

+
+
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {loading && ( +
+ +
+ )} + + {!loading && activeTab === 'metrics' && metrics && } + {!loading && activeTab === 'users' && ( + + )} + {!loading && activeTab === 'content' && ( + + )} + {!loading && activeTab === 'payments' && payments && } +
+ ); +}; + +function MetricsPanel({ metrics }: { metrics: PlatformMetrics }) { + const cards = [ + { label: 'Total Users', value: metrics.total_users.toLocaleString(), color: 'text-blue-400', icon: }, + { label: 'Active (30d)', value: metrics.active_users.toLocaleString(), color: 'text-green-400', icon: }, + { label: 'New Today', value: metrics.new_users_today.toLocaleString(), color: 'text-cyan-400', icon: }, + { label: 'New This Week', value: metrics.new_users_week.toLocaleString(), color: 'text-cyan-400', icon: }, + { label: 'Total Tracks', value: metrics.total_tracks.toLocaleString(), color: 'text-purple-400', icon: }, + { label: 'Tracks Today', value: metrics.tracks_today.toLocaleString(), color: 'text-purple-400', icon: }, + { label: 'Playlists', value: metrics.total_playlists.toLocaleString(), color: 'text-orange-400', icon: }, + { label: 'Comments', value: metrics.total_comments.toLocaleString(), color: 'text-orange-400', icon: }, + { label: 'Banned Users', value: metrics.banned_users.toLocaleString(), color: 'text-red-400', icon: }, + { label: 'Pending Reports', value: metrics.pending_reports.toLocaleString(), color: 'text-red-400', icon: }, + { label: 'Storage', value: `${(metrics.storage_used_mb / 1024).toFixed(1)} GB`, color: 'text-yellow-400', icon: }, + { label: 'Total Revenue', value: `$${metrics.total_revenue.toFixed(2)}`, color: 'text-green-400', icon: }, + ]; + + return ( +
+ {cards.map((card) => ( + +
+
{card.icon}
+ {card.label} +
+
{card.value}
+
+ ))} +
+ ); +} + +function UsersPanel({ + users, total, search, roleFilter, onSearchChange, onRoleFilterChange, onSearch, onSuspend, onUnsuspend, +}: { + users: AdminUserInfo[]; + total: number; + search: string; + roleFilter: string; + onSearchChange: (v: string) => void; + onRoleFilterChange: (v: string) => void; + onSearch: () => void; + onSuspend: (userId: string) => void; + onUnsuspend: (userId: string) => void; +}) { + return ( +
+
+
+ + onSearchChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onSearch()} + className="w-full bg-muted border border-border rounded pl-10 pr-3 py-2 text-sm text-foreground" + aria-label="Search users" + /> +
+ + + {total} users +
+ + {users.length === 0 && ( + } title="No users found" description="Try adjusting your search." /> + )} + +
+ {users.map((user) => ( + +
+
+
+ {user.username} + + {user.is_banned && BANNED} + {user.is_suspended && SUSPENDED} + {user.is_admin && ADMIN} +
+
+ {user.email} + {user.track_count} tracks + {user.active_strikes} strikes + Joined: {new Date(user.created_at).toLocaleDateString()} +
+
+
+ {!user.is_banned && !user.is_suspended ? ( + + ) : ( + + )} +
+
+
+ ))} +
+
+ ); +} + +function ContentPanel({ + content, total, search, contentType, onSearchChange, onTypeChange, onSearch, onHide, onRestore, +}: { + content: AdminContentItem[]; + total: number; + search: string; + contentType: string; + onSearchChange: (v: string) => void; + onTypeChange: (v: string) => void; + onSearch: () => void; + onHide: (id: string, type: string) => void; + onRestore: (id: string, type: string) => void; +}) { + return ( +
+
+ +
+ + onSearchChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onSearch()} + className="w-full bg-muted border border-border rounded pl-10 pr-3 py-2 text-sm text-foreground" + aria-label="Search content" + /> +
+ + {total} items +
+ + {content.length === 0 && ( + } title="No content found" description="Try adjusting your search." /> + )} + +
+ {content.map((item) => ( + +
+
+
+ + {item.title} + {item.status === 'hidden' && HIDDEN} + {item.report_count > 0 && ( + {item.report_count} reports + )} +
+
+ By: {item.creator_name} | {new Date(item.created_at).toLocaleDateString()} +
+
+
+ {item.status === 'active' ? ( + + ) : ( + + )} +
+
+
+ ))} +
+
+ ); +} + +function PaymentsPanel({ payments }: { payments: PaymentOverview }) { + const cards = [ + { label: 'Total Orders', value: payments.total_orders.toLocaleString(), color: 'text-blue-400' }, + { label: 'Completed', value: payments.completed_orders.toLocaleString(), color: 'text-green-400' }, + { label: 'Pending', value: payments.pending_orders.toLocaleString(), color: 'text-orange-400' }, + { label: 'Refunded', value: payments.refunded_orders.toLocaleString(), color: 'text-red-400' }, + { label: 'Total Revenue', value: `$${payments.total_revenue.toFixed(2)}`, color: 'text-green-400' }, + { label: 'Platform Fees', value: `$${payments.platform_fees.toFixed(2)}`, color: 'text-purple-400' }, + { label: 'Total Refunded', value: `$${payments.total_refunded.toFixed(2)}`, color: 'text-red-400' }, + ]; + + return ( +
+
+ {cards.map((card) => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 9824722ff..a12306b51 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -22,6 +22,8 @@ export { LazyPlaylistRoutes, LazySharedPlaylistPage, LazyAdminDashboard, + LazyAdminModeration, + LazyAdminPlatform, LazyAdminTransfers, LazyAnalytics, LazyWebhooks, diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index f9d52dad5..1547605ef 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -25,6 +25,8 @@ export { LazyPlaylistRoutes, LazySharedPlaylistPage, LazyAdminDashboard, + LazyAdminModeration, + LazyAdminPlatform, LazyAdminTransfers, LazyAnalytics, LazyWebhooks, diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index de8687462..42dbc7346 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -129,6 +129,22 @@ export const LazyAdminDashboard = createLazyComponent( undefined, 'Admin Dashboard', ); +export const LazyAdminModeration = createLazyComponent( + () => + import('@/features/admin/pages/AdminModerationPage').then((m) => ({ + default: m.AdminModerationPage, + })), + undefined, + 'Admin Moderation', +); +export const LazyAdminPlatform = createLazyComponent( + () => + import('@/features/admin/pages/AdminPlatformPage').then((m) => ({ + default: m.AdminPlatformPage, + })), + undefined, + 'Admin Platform', +); export const LazyAdminTransfers = createLazyComponent( () => import('@/features/admin/pages/AdminTransfersPage').then((m) => ({ diff --git a/apps/web/src/features/admin/pages/AdminModerationPage.tsx b/apps/web/src/features/admin/pages/AdminModerationPage.tsx new file mode 100644 index 000000000..3084fcc58 --- /dev/null +++ b/apps/web/src/features/admin/pages/AdminModerationPage.tsx @@ -0,0 +1,7 @@ +import { AdminModerationView } from '@/components/admin/AdminModerationView'; + +export function AdminModerationPage() { + return ; +} + +export default AdminModerationPage; diff --git a/apps/web/src/features/admin/pages/AdminPlatformPage.tsx b/apps/web/src/features/admin/pages/AdminPlatformPage.tsx new file mode 100644 index 000000000..cf3f7c0af --- /dev/null +++ b/apps/web/src/features/admin/pages/AdminPlatformPage.tsx @@ -0,0 +1,7 @@ +import { AdminPlatformView } from '@/components/admin/AdminPlatformView'; + +export function AdminPlatformPage() { + return ; +} + +export default AdminPlatformPage; diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index 6316185cb..47dd2e4a1 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -27,6 +27,8 @@ import { LazyAnalytics, LazyWebhooks, LazyAdminDashboard, + LazyAdminModeration, + LazyAdminPlatform, LazyAdminTransfers, LazyDesignSystemDemo, LazySocial, @@ -118,6 +120,8 @@ export function getProtectedRoutes(): RouteEntry[] { { path: '/analytics', element: wrapProtected() }, { path: '/webhooks', element: wrapProtected() }, { path: '/admin', element: wrapProtected() }, + { path: '/admin/moderation', element: wrapProtected() }, + { path: '/admin/platform', element: wrapProtected() }, { path: '/admin/transfers', element: wrapProtected() }, { path: '/social', element: wrapProtected() }, { path: '/feed', element: wrapProtected() }, diff --git a/apps/web/src/services/adminService.ts b/apps/web/src/services/adminService.ts index 09394f186..b80929073 100644 --- a/apps/web/src/services/adminService.ts +++ b/apps/web/src/services/adminService.ts @@ -261,6 +261,87 @@ export const adminService = { const response = await apiClient.get<{ data: { stats: ModerationStats } }>('/admin/moderation/stats'); return response.data?.data?.stats ?? { pending_reports: 0, resolved_reports: 0, pending_appeals: 0, pending_fingerprints: 0 }; }, + + // --- v0.11.3: Admin Platform Management (F421-F435) --- + + /** F421: Get platform-wide metrics */ + getPlatformMetrics: async (): Promise => { + const response = await apiClient.get<{ data: { metrics: PlatformMetrics } }>('/admin/platform/metrics'); + return response.data?.data?.metrics ?? { + total_users: 0, active_users: 0, new_users_today: 0, new_users_week: 0, + total_tracks: 0, tracks_today: 0, total_playlists: 0, total_comments: 0, + banned_users: 0, pending_reports: 0, storage_used_mb: 0, total_revenue: 0, revenue_this_month: 0, + }; + }, + + /** F422: Search/list users */ + searchUsers: async (params: { q?: string; role?: string; is_banned?: string; limit?: number; offset?: number; sort_by?: string } = {}) => { + const response = await apiClient.get<{ data: { users: AdminUserInfo[]; pagination: { total: number } } }>('/admin/platform/users', { params }); + return { + users: response.data?.data?.users ?? [], + total: response.data?.data?.pagination?.total ?? 0, + }; + }, + + /** F422: Get user detail */ + getUserDetail: async (userId: string): Promise => { + const response = await apiClient.get<{ data: { user: AdminUserInfo } }>(`/admin/platform/users/${userId}`); + return response.data?.data?.user ?? null; + }, + + /** F422: Update user role */ + updateUserRole: async (userId: string, role: string) => { + await apiClient.put(`/admin/platform/users/${userId}/role`, { role }); + return { success: true }; + }, + + /** F422: Suspend user */ + suspendUser: async (userId: string, reason: string, durationDays?: number) => { + await apiClient.post(`/admin/platform/users/${userId}/suspend`, { reason, duration_days: durationDays }); + return { success: true }; + }, + + /** F422: Unsuspend user */ + unsuspendUser: async (userId: string) => { + await apiClient.post(`/admin/platform/users/${userId}/unsuspend`, {}); + return { success: true }; + }, + + /** F423: Search content */ + searchContent: async (params: { type?: string; q?: string; limit?: number; offset?: number } = {}) => { + const response = await apiClient.get<{ data: { content: AdminContentItem[]; pagination: { total: number } } }>('/admin/platform/content', { params }); + return { + content: response.data?.data?.content ?? [], + total: response.data?.data?.pagination?.total ?? 0, + }; + }, + + /** F423: Hide content */ + hideContent: async (contentId: string, contentType: string, reason: string) => { + await apiClient.post(`/admin/platform/content/${contentId}/hide`, { content_type: contentType, reason }); + return { success: true }; + }, + + /** F423: Restore content */ + restoreContent: async (contentId: string, contentType: string) => { + await apiClient.post(`/admin/platform/content/${contentId}/restore`, { content_type: contentType }); + return { success: true }; + }, + + /** F424: Get payment overview */ + getPaymentOverview: async (): Promise => { + const response = await apiClient.get<{ data: { payments: PaymentOverview } }>('/admin/platform/payments'); + return response.data?.data?.payments ?? { + total_orders: 0, completed_orders: 0, pending_orders: 0, refunded_orders: 0, + total_revenue: 0, total_refunded: 0, platform_fees: 0, + }; + }, + + /** F424: Refund order */ + refundOrder: async (orderId: string, reason: string) => { + await apiClient.post(`/admin/platform/orders/${orderId}/refund`, { reason }); + return { success: true }; + }, }; // --- v0.11.2 Types --- @@ -329,3 +410,59 @@ export interface ModerationStats { pending_appeals: number; pending_fingerprints: number; } + +// --- v0.11.3 Types --- + +export interface PlatformMetrics { + total_users: number; + active_users: number; + new_users_today: number; + new_users_week: number; + total_tracks: number; + tracks_today: number; + total_playlists: number; + total_comments: number; + banned_users: number; + pending_reports: number; + storage_used_mb: number; + total_revenue: number; + revenue_this_month: number; +} + +export interface AdminUserInfo { + id: string; + username: string; + email: string; + role: string; + is_active: boolean; + is_banned: boolean; + is_verified: boolean; + is_admin: boolean; + track_count: number; + login_count: number; + last_login_at?: string; + created_at: string; + active_strikes: number; + is_suspended: boolean; +} + +export interface AdminContentItem { + id: string; + type: string; + title: string; + creator_id: string; + creator_name: string; + status: string; + report_count: number; + created_at: string; +} + +export interface PaymentOverview { + total_orders: number; + completed_orders: number; + pending_orders: number; + refunded_orders: number; + total_revenue: number; + total_refunded: number; + platform_fees: number; +}