veza/apps/web/src/components/admin/AdminPlatformView.tsx

455 lines
18 KiB
TypeScript
Raw Normal View History

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<PlatformTab>('metrics');
const [loading, setLoading] = useState(true);
// Metrics
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
// Users
const [users, setUsers] = useState<AdminUserInfo[]>([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
const [userRoleFilter, setUserRoleFilter] = useState('');
// Content
const [content, setContent] = useState<AdminContentItem[]>([]);
const [contentTotal, setContentTotal] = useState(0);
const [contentSearch, setContentSearch] = useState('');
const [contentType, setContentType] = useState('track');
// Payments
const [payments, setPayments] = useState<PaymentOverview | null>(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: <BarChart3 className="w-4 h-4" /> },
{ key: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ key: 'content', label: 'Content', icon: <Music className="w-4 h-4" /> },
{ key: 'payments', label: 'Payments', icon: <CreditCard className="w-4 h-4" /> },
];
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div className="flex items-center gap-3 border-b border-border/50 pb-6">
<Shield className="w-6 h-6 text-primary" />
<div>
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
PLATFORM ADMINISTRATION
</h2>
<p className="text-muted-foreground font-mono text-sm">
Metrics, users, content, and payments management.
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-border flex gap-6 mb-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors flex items-center gap-2 ${
activeTab === tab.key
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
aria-label={`${tab.label} tab`}
aria-selected={activeTab === tab.key}
role="tab"
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-24">
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
</div>
)}
{!loading && activeTab === 'metrics' && metrics && <MetricsPanel metrics={metrics} />}
{!loading && activeTab === 'users' && (
<UsersPanel
users={users}
total={usersTotal}
search={userSearch}
roleFilter={userRoleFilter}
onSearchChange={setUserSearch}
onRoleFilterChange={setUserRoleFilter}
onSearch={loadUsers}
onSuspend={handleSuspendUser}
onUnsuspend={handleUnsuspendUser}
/>
)}
{!loading && activeTab === 'content' && (
<ContentPanel
content={content}
total={contentTotal}
search={contentSearch}
contentType={contentType}
onSearchChange={setContentSearch}
onTypeChange={setContentType}
onSearch={loadContent}
onHide={handleHideContent}
onRestore={handleRestoreContent}
/>
)}
{!loading && activeTab === 'payments' && payments && <PaymentsPanel payments={payments} />}
</div>
);
};
function MetricsPanel({ metrics }: { metrics: PlatformMetrics }) {
const cards = [
{ label: 'Total Users', value: metrics.total_users.toLocaleString(), color: 'text-blue-400', icon: <Users className="w-5 h-5" /> },
{ label: 'Active (30d)', value: metrics.active_users.toLocaleString(), color: 'text-green-400', icon: <CheckCircle className="w-5 h-5" /> },
{ label: 'New Today', value: metrics.new_users_today.toLocaleString(), color: 'text-cyan-400', icon: <Users className="w-5 h-5" /> },
{ label: 'New This Week', value: metrics.new_users_week.toLocaleString(), color: 'text-cyan-400', icon: <Users className="w-5 h-5" /> },
{ label: 'Total Tracks', value: metrics.total_tracks.toLocaleString(), color: 'text-purple-400', icon: <Music className="w-5 h-5" /> },
{ label: 'Tracks Today', value: metrics.tracks_today.toLocaleString(), color: 'text-purple-400', icon: <Music className="w-5 h-5" /> },
{ label: 'Playlists', value: metrics.total_playlists.toLocaleString(), color: 'text-orange-400', icon: <FileText className="w-5 h-5" /> },
{ label: 'Comments', value: metrics.total_comments.toLocaleString(), color: 'text-orange-400', icon: <FileText className="w-5 h-5" /> },
{ label: 'Banned Users', value: metrics.banned_users.toLocaleString(), color: 'text-red-400', icon: <Ban className="w-5 h-5" /> },
{ label: 'Pending Reports', value: metrics.pending_reports.toLocaleString(), color: 'text-red-400', icon: <FileText className="w-5 h-5" /> },
{ label: 'Storage', value: `${(metrics.storage_used_mb / 1024).toFixed(1)} GB`, color: 'text-yellow-400', icon: <BarChart3 className="w-5 h-5" /> },
{ label: 'Total Revenue', value: `$${metrics.total_revenue.toFixed(2)}`, color: 'text-green-400', icon: <CreditCard className="w-5 h-5" /> },
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<Card key={card.label} variant="default" className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className={card.color}>{card.icon}</div>
<span className="text-xs text-muted-foreground uppercase">{card.label}</span>
</div>
<div className={`text-2xl font-bold ${card.color}`}>{card.value}</div>
</Card>
))}
</div>
);
}
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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search username or email..."
value={search}
onChange={(e) => 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"
/>
</div>
<select
value={roleFilter}
onChange={(e) => { onRoleFilterChange(e.target.value); }}
className="bg-muted border border-border rounded px-2 py-2 text-sm text-foreground"
aria-label="Filter by role"
>
<option value="">All Roles</option>
<option value="user">User</option>
<option value="creator">Creator</option>
<option value="artist">Artist</option>
<option value="premium">Premium</option>
<option value="admin">Admin</option>
</select>
<Button variant="secondary" size="sm" icon={<Search className="w-4 h-4" />} onClick={onSearch}>
Search
</Button>
<span className="text-xs text-muted-foreground ml-auto">{total} users</span>
</div>
{users.length === 0 && (
<EmptyState icon={<Users className="w-full h-full" />} title="No users found" description="Try adjusting your search." />
)}
<div className="space-y-2">
{users.map((user) => (
<Card key={user.id} variant="default" className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className="font-bold text-foreground">{user.username}</span>
<Badge label={user.role} variant="terminal" />
{user.is_banned && <span className="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded">BANNED</span>}
{user.is_suspended && <span className="text-xs bg-orange-500/20 text-orange-400 px-2 py-0.5 rounded">SUSPENDED</span>}
{user.is_admin && <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded">ADMIN</span>}
</div>
<div className="text-xs text-muted-foreground flex gap-4">
<span>{user.email}</span>
<span>{user.track_count} tracks</span>
<span>{user.active_strikes} strikes</span>
<span>Joined: {new Date(user.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
{!user.is_banned && !user.is_suspended ? (
<Button variant="primary" size="sm" className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground" icon={<Ban className="w-4 h-4" />} onClick={() => onSuspend(user.id)}>
Suspend
</Button>
) : (
<Button variant="secondary" size="sm" icon={<CheckCircle className="w-4 h-4" />} onClick={() => onUnsuspend(user.id)}>
Unsuspend
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
);
}
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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center">
<select
value={contentType}
onChange={(e) => onTypeChange(e.target.value)}
className="bg-muted border border-border rounded px-2 py-2 text-sm text-foreground"
aria-label="Content type"
>
<option value="track">Tracks</option>
<option value="comment">Comments</option>
</select>
<div className="relative flex-1 min-w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search content..."
value={search}
onChange={(e) => 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"
/>
</div>
<Button variant="secondary" size="sm" icon={<Search className="w-4 h-4" />} onClick={onSearch}>
Search
</Button>
<span className="text-xs text-muted-foreground ml-auto">{total} items</span>
</div>
{content.length === 0 && (
<EmptyState icon={<Music className="w-full h-full" />} title="No content found" description="Try adjusting your search." />
)}
<div className="space-y-2">
{content.map((item) => (
<Card key={item.id} variant="default" className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<Badge label={item.type} variant="terminal" />
<span className="font-bold text-foreground">{item.title}</span>
{item.status === 'hidden' && <span className="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded">HIDDEN</span>}
{item.report_count > 0 && (
<span className="text-xs bg-orange-500/20 text-orange-400 px-2 py-0.5 rounded">{item.report_count} reports</span>
)}
</div>
<div className="text-xs text-muted-foreground">
By: {item.creator_name} | {new Date(item.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex gap-2">
{item.status === 'active' ? (
<Button variant="secondary" size="sm" icon={<EyeOff className="w-4 h-4" />} onClick={() => onHide(item.id, item.type)}>
Hide
</Button>
) : (
<Button variant="secondary" size="sm" icon={<Eye className="w-4 h-4" />} onClick={() => onRestore(item.id, item.type)}>
Restore
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
);
}
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 (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((card) => (
<div key={card.label} className="bg-muted/30 rounded-lg p-4 border border-border/50">
<div className="text-xs text-muted-foreground uppercase mb-1">{card.label}</div>
<div className={`text-2xl font-bold ${card.color}`}>{card.value}</div>
</div>
))}
</div>
</div>
);
}