feat(v0.11.2): F411-F420 frontend advanced moderation components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
025c7aae45
commit
4fe689ddfd
2 changed files with 685 additions and 139 deletions
|
|
@ -1,190 +1,576 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
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 { Report } from '../../types';
|
||||
import {
|
||||
ShieldAlert,
|
||||
CheckCircle,
|
||||
Ban,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Fingerprint,
|
||||
Scale,
|
||||
Filter,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../components/feedback/ToastProvider';
|
||||
import { adminService } from '../../services/adminService';
|
||||
import {
|
||||
adminService,
|
||||
ModerationQueueItem,
|
||||
SpamDetectionItem,
|
||||
FingerprintItem,
|
||||
StrikeInfo,
|
||||
ModerationStats,
|
||||
} from '../../services/adminService';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
type ModerationTab = 'queue' | 'spam' | 'fingerprints' | 'appeals';
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
high: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
normal: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
low: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
spam: 'Spam',
|
||||
offensive: 'Offensive',
|
||||
copyright: 'Copyright',
|
||||
fake: 'Fake/Impersonation',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export const AdminModerationView: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [queue, setQueue] = useState<Report[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'pending' | 'reviewed' | 'resolved'
|
||||
>('pending');
|
||||
const [activeTab, setActiveTab] = useState<ModerationTab>('queue');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadQueue = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getModerationQueue('all');
|
||||
setQueue(data);
|
||||
} catch (e) {
|
||||
logger.error('Error loading moderation queue', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadQueue();
|
||||
// Queue state
|
||||
const [queue, setQueue] = useState<ModerationQueueItem[]>([]);
|
||||
const [queueTotal, setQueueTotal] = useState(0);
|
||||
const [queueFilter, setQueueFilter] = useState({ status: 'pending', category: '', priority: '' });
|
||||
|
||||
// Spam detections
|
||||
const [spamDetections, setSpamDetections] = useState<SpamDetectionItem[]>([]);
|
||||
const [spamTotal, setSpamTotal] = useState(0);
|
||||
|
||||
// Fingerprints
|
||||
const [fingerprints, setFingerprints] = useState<FingerprintItem[]>([]);
|
||||
const [fingerprintTotal, setFingerprintTotal] = useState(0);
|
||||
|
||||
// Appeals
|
||||
const [appeals, setAppeals] = useState<StrikeInfo[]>([]);
|
||||
const [appealsTotal, setAppealsTotal] = useState(0);
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState<ModerationStats | null>(null);
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [queueData, statsData] = await Promise.all([
|
||||
adminService.getAdvancedModerationQueue({
|
||||
status: queueFilter.status,
|
||||
category: queueFilter.category || undefined,
|
||||
priority: queueFilter.priority || undefined,
|
||||
sort_by: 'priority',
|
||||
}),
|
||||
adminService.getModerationStats(),
|
||||
]);
|
||||
setQueue(queueData.reports);
|
||||
setQueueTotal(queueData.pagination.total);
|
||||
setStats(statsData);
|
||||
} catch (e) {
|
||||
logger.error('Error loading moderation queue', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queueFilter]);
|
||||
|
||||
const loadSpam = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getSpamDetections();
|
||||
setSpamDetections(data.detections);
|
||||
setSpamTotal(data.total);
|
||||
} catch (e) {
|
||||
logger.error('Error loading spam detections', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredQueue = queue.filter((r) =>
|
||||
activeTab === 'pending'
|
||||
? r.status === 'pending'
|
||||
: activeTab === 'reviewed'
|
||||
? r.status === 'reviewed'
|
||||
: r.status === 'resolved' || r.status === 'dismissed',
|
||||
);
|
||||
|
||||
const handleAction = async (id: string, action: string) => {
|
||||
const loadFingerprints = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await adminService.resolveReport(id, action);
|
||||
addToast(`Report ${action}`, 'success');
|
||||
const newStatus = (action === 'dismissed' ? 'dismissed' : 'resolved') as Report['status'];
|
||||
setQueue((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, status: newStatus } : r)),
|
||||
);
|
||||
const data = await adminService.getPendingFingerprints();
|
||||
setFingerprints(data.fingerprints);
|
||||
setFingerprintTotal(data.total);
|
||||
} catch (e) {
|
||||
logger.error('Error loading fingerprints', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAppeals = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getPendingAppeals();
|
||||
setAppeals(data.appeals);
|
||||
setAppealsTotal(data.total);
|
||||
} catch (e) {
|
||||
logger.error('Error loading appeals', { error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
switch (activeTab) {
|
||||
case 'queue': loadQueue(); break;
|
||||
case 'spam': loadSpam(); break;
|
||||
case 'fingerprints': loadFingerprints(); break;
|
||||
case 'appeals': loadAppeals(); break;
|
||||
}
|
||||
}, [activeTab, loadQueue, loadSpam, loadFingerprints, loadAppeals]);
|
||||
|
||||
const handleProcessReport = async (id: string, action: string) => {
|
||||
try {
|
||||
await adminService.processReport(id, action);
|
||||
addToast(`Report ${action}`, 'success');
|
||||
loadQueue();
|
||||
} catch {
|
||||
addToast('Action failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewFingerprint = async (trackId: string, status: 'clean' | 'matched') => {
|
||||
try {
|
||||
await adminService.reviewFingerprint(trackId, status);
|
||||
addToast(`Fingerprint marked as ${status}`, 'success');
|
||||
loadFingerprints();
|
||||
} catch {
|
||||
addToast('Review failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolveAppeal = async (strikeId: string, upheld: boolean) => {
|
||||
try {
|
||||
await adminService.resolveAppeal(strikeId, upheld);
|
||||
addToast(`Appeal ${upheld ? 'upheld' : 'overturned'}`, 'success');
|
||||
loadAppeals();
|
||||
} catch {
|
||||
addToast('Resolution failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: ModerationTab; label: string; count: number; icon: React.ReactNode }[] = [
|
||||
{ key: 'queue', label: 'Queue', count: stats?.pending_reports ?? 0, icon: <ShieldAlert className="w-4 h-4" /> },
|
||||
{ key: 'spam', label: 'Spam', count: spamTotal, icon: <AlertTriangle className="w-4 h-4" /> },
|
||||
{ key: 'fingerprints', label: 'Fingerprints', count: stats?.pending_fingerprints ?? 0, icon: <Fingerprint className="w-4 h-4" /> },
|
||||
{ key: 'appeals', label: 'Appeals', count: stats?.pending_appeals ?? 0, icon: <Scale 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">
|
||||
<ShieldAlert className="w-6 h-6 text-destructive" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
|
||||
MODERATION QUEUE
|
||||
ADVANCED MODERATION
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
Review and act on flagged content.
|
||||
Queue, spam detection, fingerprints, and appeals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Pending Reports', value: stats.pending_reports, color: 'text-orange-400' },
|
||||
{ label: 'Resolved', value: stats.resolved_reports, color: 'text-green-400' },
|
||||
{ label: 'Pending Appeals', value: stats.pending_appeals, color: 'text-blue-400' },
|
||||
{ label: 'Pending Fingerprints', value: stats.pending_fingerprints, color: 'text-purple-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-muted/30 rounded-lg p-3 border border-border/50">
|
||||
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
<div className="text-xs text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex gap-6 mb-6">
|
||||
{['pending', 'reviewed', 'resolved'].map((tab) => (
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as 'pending' | 'reviewed' | 'resolved')}
|
||||
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-destructive text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
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-destructive text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
aria-label={`${tab.label} tab`}
|
||||
aria-selected={activeTab === tab.key}
|
||||
role="tab"
|
||||
>
|
||||
{tab} (
|
||||
{
|
||||
queue.filter((r) =>
|
||||
tab === 'pending'
|
||||
? r.status === 'pending'
|
||||
: tab === 'reviewed'
|
||||
? r.status === 'reviewed'
|
||||
: r.status === 'resolved' || r.status === 'dismissed',
|
||||
).length
|
||||
}
|
||||
)
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className="bg-destructive/20 text-destructive text-xs px-1.5 py-0.5 rounded-full">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading && (
|
||||
<div className="flex justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredQueue.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<ShieldAlert className="w-full h-full" />}
|
||||
title="All caught up!"
|
||||
description="No reports in this queue."
|
||||
/>
|
||||
)}
|
||||
{!loading && activeTab === 'queue' && (
|
||||
<QueuePanel
|
||||
queue={queue}
|
||||
total={queueTotal}
|
||||
filter={queueFilter}
|
||||
onFilterChange={setQueueFilter}
|
||||
onProcess={handleProcessReport}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
filteredQueue.map((report) => (
|
||||
<Card
|
||||
key={report.id}
|
||||
variant="default"
|
||||
className="border-l-4 border-l-destructive"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<Badge label={report.targetType} variant="terminal" />
|
||||
<span className="font-bold text-foreground text-lg">
|
||||
{report.targetName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {report.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-4 rounded border border-border mb-3">
|
||||
<div className="text-xs font-bold text-destructive uppercase mb-1">
|
||||
Reason: {report.reason}
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
{report.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Reported by:{' '}
|
||||
<span className="text-foreground">{report.reportedBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!loading && activeTab === 'spam' && (
|
||||
<SpamPanel detections={spamDetections} total={spamTotal} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 justify-center min-w-36">
|
||||
<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={() => handleAction(report.id, 'banned')}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={() => handleAction(report.id, 'resolved')}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => addToast('Warning sent')}
|
||||
>
|
||||
Send Warning
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleAction(report.id, 'dismissed')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{!loading && activeTab === 'fingerprints' && (
|
||||
<FingerprintPanel
|
||||
fingerprints={fingerprints}
|
||||
total={fingerprintTotal}
|
||||
onReview={handleReviewFingerprint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'appeals' && (
|
||||
<AppealsPanel
|
||||
appeals={appeals}
|
||||
total={appealsTotal}
|
||||
onResolve={handleResolveAppeal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-panels ---
|
||||
|
||||
function QueuePanel({
|
||||
queue, total, filter, onFilterChange, onProcess,
|
||||
}: {
|
||||
queue: ModerationQueueItem[];
|
||||
total: number;
|
||||
filter: { status: string; category: string; priority: string };
|
||||
onFilterChange: (f: { status: string; category: string; priority: string }) => void;
|
||||
onProcess: (id: string, action: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<select
|
||||
value={filter.status}
|
||||
onChange={(e) => onFilterChange({ ...filter, status: e.target.value })}
|
||||
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
|
||||
aria-label="Filter by status"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="all">All</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
<select
|
||||
value={filter.category}
|
||||
onChange={(e) => onFilterChange({ ...filter, category: e.target.value })}
|
||||
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
|
||||
aria-label="Filter by category"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="spam">Spam</option>
|
||||
<option value="offensive">Offensive</option>
|
||||
<option value="copyright">Copyright</option>
|
||||
<option value="fake">Fake</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select
|
||||
value={filter.priority}
|
||||
onChange={(e) => onFilterChange({ ...filter, priority: e.target.value })}
|
||||
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
|
||||
aria-label="Filter by priority"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{total} total</span>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<ShieldAlert className="w-full h-full" />}
|
||||
title="All caught up!"
|
||||
description="No reports in this queue."
|
||||
/>
|
||||
)}
|
||||
|
||||
{queue.map((item) => (
|
||||
<Card key={item.id} variant="default" className="border-l-4 border-l-destructive">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<Badge label={item.content_type} variant="terminal" />
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal}`}>
|
||||
{item.priority}
|
||||
</span>
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded text-muted-foreground">
|
||||
{CATEGORY_LABELS[item.category] ?? item.category}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {new Date(item.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded border border-border mb-2">
|
||||
<p className="text-sm text-foreground">{item.reason}</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{item.reporter_name && <span>Reporter: <span className="text-foreground">{item.reporter_name}</span></span>}
|
||||
{item.reported_name && <span>Reported: <span className="text-foreground">{item.reported_name}</span></span>}
|
||||
{item.assigned_to && <span>Assigned: <span className="text-foreground">{item.assigned_to.slice(0, 8)}...</span></span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.status === 'pending' && (
|
||||
<div className="flex flex-col gap-2 justify-center min-w-36">
|
||||
<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={() => onProcess(item.id, 'ban_temp')}
|
||||
>
|
||||
Temp Ban
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
onClick={() => onProcess(item.id, 'reject')}
|
||||
>
|
||||
Reject Content
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
onClick={() => onProcess(item.id, 'warn')}
|
||||
>
|
||||
Warn User
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={() => onProcess(item.id, 'approve')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => onProcess(item.id, 'dismiss')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status !== 'pending' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
{item.resolution_action ?? item.status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpamPanel({ detections, total }: { detections: SpamDetectionItem[]; total: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{total} unreviewed spam detections</p>
|
||||
|
||||
{detections.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<AlertTriangle className="w-full h-full" />}
|
||||
title="No spam detected"
|
||||
description="All spam detections have been reviewed."
|
||||
/>
|
||||
)}
|
||||
|
||||
{detections.map((d) => (
|
||||
<Card key={d.id} variant="default">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-400" />
|
||||
<span className="font-bold text-foreground">{d.rule_name}</span>
|
||||
<Badge label={d.content_type} variant="terminal" />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
User: {d.user_id.slice(0, 8)}... | Action: {d.action_taken} | {new Date(d.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FingerprintPanel({
|
||||
fingerprints, total, onReview,
|
||||
}: {
|
||||
fingerprints: FingerprintItem[];
|
||||
total: number;
|
||||
onReview: (trackId: string, status: 'clean' | 'matched') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{total} fingerprints awaiting review</p>
|
||||
|
||||
{fingerprints.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<Fingerprint className="w-full h-full" />}
|
||||
title="No pending fingerprints"
|
||||
description="All audio fingerprints have been reviewed."
|
||||
/>
|
||||
)}
|
||||
|
||||
{fingerprints.map((fp) => (
|
||||
<Card key={fp.track_id} variant="default">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Fingerprint className="w-4 h-4 text-purple-400" />
|
||||
<span className="font-bold text-foreground">Track: {fp.track_id.slice(0, 8)}...</span>
|
||||
{fp.confidence != null && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${fp.confidence > 80 ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
|
||||
{fp.confidence}% match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fp.matched_title && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Matched: <span className="text-foreground">{fp.matched_title}</span>
|
||||
{fp.matched_artist && <> by <span className="text-foreground">{fp.matched_artist}</span></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => onReview(fp.track_id, 'clean')}>
|
||||
Mark Clean
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground"
|
||||
onClick={() => onReview(fp.track_id, 'matched')}
|
||||
>
|
||||
Confirm Match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppealsPanel({
|
||||
appeals, total, onResolve,
|
||||
}: {
|
||||
appeals: StrikeInfo[];
|
||||
total: number;
|
||||
onResolve: (strikeId: string, upheld: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{total} appeals pending review</p>
|
||||
|
||||
{appeals.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<Scale className="w-full h-full" />}
|
||||
title="No pending appeals"
|
||||
description="All strike appeals have been resolved."
|
||||
/>
|
||||
)}
|
||||
|
||||
{appeals.map((appeal) => (
|
||||
<Card key={appeal.id} variant="default">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Scale className="w-4 h-4 text-blue-400" />
|
||||
<span className="font-bold text-foreground">Strike: {appeal.id.slice(0, 8)}...</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
appeal.severity === 'major' ? 'bg-red-500/20 text-red-400' :
|
||||
appeal.severity === 'minor' ? 'bg-orange-500/20 text-orange-400' :
|
||||
'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{appeal.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{appeal.reason}</p>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Issued: {new Date(appeal.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
onClick={() => onResolve(appeal.id, false)}
|
||||
>
|
||||
Overturn
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={() => onResolve(appeal.id, true)}
|
||||
>
|
||||
Uphold
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,4 +168,164 @@ export const adminService = {
|
|||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- v0.11.2: Advanced Moderation (F411-F420) ---
|
||||
|
||||
/** F411: Get advanced moderation queue with filters */
|
||||
getAdvancedModerationQueue: async (params: {
|
||||
status?: string;
|
||||
category?: string;
|
||||
priority?: string;
|
||||
sort_by?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}) => {
|
||||
const response = await apiClient.get<{ data: { reports: ModerationQueueItem[]; pagination: { total: number; limit: number; offset: number } } }>('/admin/moderation/queue', { params });
|
||||
return {
|
||||
reports: response.data?.data?.reports ?? [],
|
||||
pagination: response.data?.data?.pagination ?? { total: 0, limit: 20, offset: 0 },
|
||||
};
|
||||
},
|
||||
|
||||
/** F411: Process a report with advanced actions */
|
||||
processReport: async (id: string, action: string, reason?: string, banDurationDays?: number) => {
|
||||
await apiClient.post(`/admin/moderation/reports/${id}/process`, {
|
||||
action,
|
||||
reason: reason ?? '',
|
||||
ban_duration_days: banDurationDays,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F411: Assign report to a moderator */
|
||||
assignReport: async (reportId: string, moderatorId: string) => {
|
||||
await apiClient.post(`/admin/moderation/reports/${reportId}/assign`, {
|
||||
moderator_id: moderatorId,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F413: Get spam detections */
|
||||
getSpamDetections: async (limit = 20, offset = 0) => {
|
||||
const response = await apiClient.get<{ data: { detections: SpamDetectionItem[]; pagination: { total: number } } }>('/admin/moderation/spam', {
|
||||
params: { limit, offset },
|
||||
});
|
||||
return {
|
||||
detections: response.data?.data?.detections ?? [],
|
||||
total: response.data?.data?.pagination?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F414: Get pending audio fingerprints */
|
||||
getPendingFingerprints: async (limit = 20, offset = 0) => {
|
||||
const response = await apiClient.get<{ data: { fingerprints: FingerprintItem[]; pagination: { total: number } } }>('/admin/moderation/fingerprints', {
|
||||
params: { limit, offset },
|
||||
});
|
||||
return {
|
||||
fingerprints: response.data?.data?.fingerprints ?? [],
|
||||
total: response.data?.data?.pagination?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F414: Review a fingerprint */
|
||||
reviewFingerprint: async (trackId: string, status: 'clean' | 'matched') => {
|
||||
await apiClient.post(`/admin/moderation/fingerprints/${trackId}/review`, { status });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** F415: Get user strikes */
|
||||
getUserStrikes: async (userId: string) => {
|
||||
const response = await apiClient.get<{ data: { strikes: UserStrikeSummary } }>(`/admin/moderation/users/${userId}/strikes`);
|
||||
return response.data?.data?.strikes ?? null;
|
||||
},
|
||||
|
||||
/** F415: Get pending appeals */
|
||||
getPendingAppeals: async (limit = 20, offset = 0) => {
|
||||
const response = await apiClient.get<{ data: { appeals: StrikeInfo[]; pagination: { total: number } } }>('/admin/moderation/appeals', {
|
||||
params: { limit, offset },
|
||||
});
|
||||
return {
|
||||
appeals: response.data?.data?.appeals ?? [],
|
||||
total: response.data?.data?.pagination?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
/** F415: Resolve an appeal */
|
||||
resolveAppeal: async (strikeId: string, upheld: boolean) => {
|
||||
await apiClient.post(`/admin/moderation/appeals/${strikeId}/resolve`, { upheld });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** Get moderation stats */
|
||||
getModerationStats: async () => {
|
||||
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.2 Types ---
|
||||
|
||||
export interface ModerationQueueItem {
|
||||
id: string;
|
||||
reporter_id: string;
|
||||
reported_user_id?: string;
|
||||
content_type: string;
|
||||
content_id?: string;
|
||||
reason: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
assigned_to?: string;
|
||||
resolution_note?: string;
|
||||
resolution_action?: string;
|
||||
resolved_by?: string;
|
||||
resolved_at?: string;
|
||||
created_at: string;
|
||||
reporter_name?: string;
|
||||
reported_name?: string;
|
||||
}
|
||||
|
||||
export interface SpamDetectionItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
rule_name: string;
|
||||
content_type: string;
|
||||
action_taken: string;
|
||||
reviewed: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FingerprintItem {
|
||||
track_id: string;
|
||||
status: string;
|
||||
matched_title?: string;
|
||||
matched_artist?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface StrikeInfo {
|
||||
id: string;
|
||||
reason: string;
|
||||
severity: string;
|
||||
is_active: boolean;
|
||||
appealed: boolean;
|
||||
appeal_result?: string;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserStrikeSummary {
|
||||
user_id: string;
|
||||
active_strikes: number;
|
||||
total_strikes: number;
|
||||
is_suspended: boolean;
|
||||
suspended_until?: string;
|
||||
strikes: StrikeInfo[];
|
||||
}
|
||||
|
||||
export interface ModerationStats {
|
||||
pending_reports: number;
|
||||
resolved_reports: number;
|
||||
pending_appeals: number;
|
||||
pending_fingerprints: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue