initial commit: rocket.new export of broadcastbeat
This commit is contained in:
565
src/app/admin/forum-dashboard/page.tsx
Normal file
565
src/app/admin/forum-dashboard/page.tsx
Normal file
@@ -0,0 +1,565 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WeeklyGrowth {
|
||||
week: string;
|
||||
threads: number;
|
||||
replies: number;
|
||||
}
|
||||
|
||||
interface TopCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
thread_count: number;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
interface ActiveUser {
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
threads: number;
|
||||
replies: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface EngagementMetrics {
|
||||
totalThreads: number;
|
||||
totalReplies: number;
|
||||
totalViews: number;
|
||||
totalVotes: number;
|
||||
avgRepliesPerThread: number;
|
||||
engagementRate: number;
|
||||
newThreadsWeek: number;
|
||||
newRepliesWeek: number;
|
||||
}
|
||||
|
||||
interface ModerationStats {
|
||||
flaggedThreads: number;
|
||||
lockedThreads: number;
|
||||
pinnedThreads: number;
|
||||
featuredThreads: number;
|
||||
flaggedReplies: number;
|
||||
hiddenReplies: number;
|
||||
}
|
||||
|
||||
interface ForumStats {
|
||||
weeklyGrowth: WeeklyGrowth[];
|
||||
topCategories: TopCategory[];
|
||||
activeUsers: ActiveUser[];
|
||||
engagement: EngagementMetrics;
|
||||
moderation: ModerationStats;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
sub?: string;
|
||||
accentColor: string;
|
||||
bgColor: string;
|
||||
icon: React.ReactNode;
|
||||
badge?: { text: string; color: string };
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, accentColor, bgColor, icon, badge }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-3 hover:border-[#333] transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${bgColor}`}>
|
||||
<span className={accentColor}>{icon}</span>
|
||||
</div>
|
||||
{badge && (
|
||||
<span className={`text-xs font-body font-bold px-2 py-0.5 rounded-full ${badge.color}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display font-bold text-white">{value}</p>
|
||||
<p className="text-sm text-[#888] font-body mt-0.5">{label}</p>
|
||||
{sub && <p className="text-xs text-[#555] font-body mt-1">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tooltip ───────────────────────────────────────────────────────────
|
||||
|
||||
function ChartTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
||||
{label && <p className="text-[#888] mb-1">{label}</p>}
|
||||
{payload.map((entry: any, i: number) => (
|
||||
<p key={i} style={{ color: entry.color }} className="leading-5">
|
||||
{entry.name}: <span className="font-bold text-white">{entry.value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Header ───────────────────────────────────────────────────────────
|
||||
|
||||
function SectionHeader({ title, subtitle, action }: { title: string; subtitle?: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-white text-base font-bold font-display uppercase tracking-wider">{title}</h2>
|
||||
{subtitle && <p className="text-[#555] text-xs font-body mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_STATS: ForumStats = {
|
||||
weeklyGrowth: [],
|
||||
topCategories: [],
|
||||
activeUsers: [],
|
||||
engagement: {
|
||||
totalThreads: 0,
|
||||
totalReplies: 0,
|
||||
totalViews: 0,
|
||||
totalVotes: 0,
|
||||
avgRepliesPerThread: 0,
|
||||
engagementRate: 0,
|
||||
newThreadsWeek: 0,
|
||||
newRepliesWeek: 0,
|
||||
},
|
||||
moderation: {
|
||||
flaggedThreads: 0,
|
||||
lockedThreads: 0,
|
||||
pinnedThreads: 0,
|
||||
featuredThreads: 0,
|
||||
flaggedReplies: 0,
|
||||
hiddenReplies: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#8b5cf6', '#ef4444', '#06b6d4', '#f97316', '#84cc16'];
|
||||
|
||||
export default function ForumDashboardPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [stats, setStats] = useState<ForumStats>(EMPTY_STATS);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.push('/login');
|
||||
}, [user, loading, router]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/forum-stats');
|
||||
if (!res.ok) throw new Error('Failed to fetch forum stats');
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Unknown error');
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) fetchStats();
|
||||
}, [user, fetchStats]);
|
||||
|
||||
if (loading || loadingData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-[#555] text-sm font-body">Loading forum dashboard…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { engagement, moderation, weeklyGrowth, topCategories, activeUsers } = stats;
|
||||
const totalFlagged = moderation.flaggedThreads + moderation.flaggedReplies;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link href="/admin" className="text-[#555] hover:text-white text-sm font-body transition-colors">Admin</Link>
|
||||
<svg className="w-3.5 h-3.5 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-[#888] text-sm font-body">Forum Dashboard</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-display font-bold text-white">Forum Dashboard</h1>
|
||||
<p className="text-[#555] text-sm font-body mt-1">Thread growth, engagement, and moderation overview</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#252525] rounded-lg text-xs text-[#888] hover:text-white hover:border-[#333] transition-all font-body"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/forum-moderation"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#3b82f6] rounded-lg text-xs text-white hover:bg-blue-500 transition-colors font-body font-semibold"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Moderation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01" />
|
||||
</svg>
|
||||
<p className="text-red-400 text-sm font-body">{error}</p>
|
||||
<button onClick={fetchStats} className="ml-auto text-xs text-red-400 hover:text-red-300 underline font-body">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flagged Content Alert */}
|
||||
{totalFlagged > 0 && (
|
||||
<div className="mb-6 bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
<p className="text-red-400 text-sm font-body">
|
||||
<span className="font-bold">{totalFlagged} flagged item{totalFlagged !== 1 ? 's' : ''}</span> require moderation review
|
||||
</p>
|
||||
<Link href="/admin/forum-moderation" className="ml-auto text-xs text-red-400 hover:text-red-300 underline font-body">Review now →</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engagement Metric Cards */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-4">Engagement Metrics</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Threads"
|
||||
value={fmt(engagement.totalThreads)}
|
||||
sub={`+${engagement.newThreadsWeek} this week`}
|
||||
accentColor="text-blue-400"
|
||||
bgColor="bg-blue-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Replies"
|
||||
value={fmt(engagement.totalReplies)}
|
||||
sub={`+${engagement.newRepliesWeek} this week`}
|
||||
accentColor="text-green-400"
|
||||
bgColor="bg-green-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Views"
|
||||
value={fmt(engagement.totalViews)}
|
||||
sub="Across all threads"
|
||||
accentColor="text-purple-400"
|
||||
bgColor="bg-purple-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Votes"
|
||||
value={fmt(engagement.totalVotes)}
|
||||
sub="Upvotes cast"
|
||||
accentColor="text-amber-400"
|
||||
bgColor="bg-amber-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Replies/Thread"
|
||||
value={engagement.avgRepliesPerThread}
|
||||
sub="Discussion depth"
|
||||
accentColor="text-cyan-400"
|
||||
bgColor="bg-cyan-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Engagement Rate"
|
||||
value={`${engagement.engagementRate}%`}
|
||||
sub="Threads with replies"
|
||||
accentColor="text-pink-400"
|
||||
bgColor="bg-pink-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Flagged Content"
|
||||
value={totalFlagged}
|
||||
sub={`${moderation.flaggedThreads} threads · ${moderation.flaggedReplies} replies`}
|
||||
accentColor="text-red-400"
|
||||
bgColor="bg-red-500/10"
|
||||
badge={totalFlagged > 0 ? { text: 'Needs review', color: 'text-red-400 bg-red-400/10' } : undefined}
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Locked Threads"
|
||||
value={moderation.lockedThreads}
|
||||
sub={`${moderation.pinnedThreads} pinned · ${moderation.featuredThreads} featured`}
|
||||
accentColor="text-orange-400"
|
||||
bgColor="bg-orange-500/10"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Thread Growth Chart */}
|
||||
<section className="mb-8">
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
||||
<SectionHeader
|
||||
title="Thread Growth"
|
||||
subtitle="Weekly new threads and replies over the past 8 weeks"
|
||||
/>
|
||||
{weeklyGrowth.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<p className="text-[#555] text-sm font-body">No data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={weeklyGrowth} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
|
||||
<XAxis dataKey="week" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '11px', color: '#888', paddingTop: '12px' }}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
/>
|
||||
<Line type="monotone" dataKey="threads" stroke="#3b82f6" strokeWidth={2} dot={{ fill: '#3b82f6', r: 3 }} name="New Threads" />
|
||||
<Line type="monotone" dataKey="replies" stroke="#10b981" strokeWidth={2} dot={{ fill: '#10b981', r: 3 }} name="New Replies" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Two-column: Top Categories + Active Users */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
|
||||
{/* Top Categories */}
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-display font-bold text-white">Top Categories</h2>
|
||||
<p className="text-xs text-[#555] font-body mt-0.5">By thread count</p>
|
||||
</div>
|
||||
<Link href="/forum" className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">View forum →</Link>
|
||||
</div>
|
||||
{topCategories.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center">
|
||||
<p className="text-[#555] text-sm font-body">No categories found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#1a1a1a]">
|
||||
{topCategories.map((cat, idx) => {
|
||||
const maxThreads = topCategories[0]?.thread_count ?? 1;
|
||||
const pct = maxThreads > 0 ? Math.round((cat.thread_count / maxThreads) * 100) : 0;
|
||||
const color = CATEGORY_COLORS[idx % CATEGORY_COLORS.length];
|
||||
return (
|
||||
<div key={cat.id} className="px-5 py-3 hover:bg-[#0d0d0d] transition-colors">
|
||||
<div className="flex items-center gap-3 mb-1.5">
|
||||
<span className="text-base leading-none">{cat.icon}</span>
|
||||
<span className="text-sm text-white font-body flex-1 truncate">{cat.name}</span>
|
||||
<span className="text-xs text-[#888] font-body font-semibold">{cat.thread_count} threads</span>
|
||||
<span className="text-xs text-[#555] font-body">{cat.post_count} posts</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden ml-7">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Users */}
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#1a1a1a]">
|
||||
<h2 className="text-sm font-display font-bold text-white">Active Users</h2>
|
||||
<p className="text-xs text-[#555] font-body mt-0.5">Most active in the last 30 days</p>
|
||||
</div>
|
||||
{activeUsers.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center">
|
||||
<p className="text-[#555] text-sm font-body">No active users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#1a1a1a]">
|
||||
{activeUsers.map((u, idx) => (
|
||||
<div key={u.author_id} className="px-5 py-3 flex items-center gap-3 hover:bg-[#0d0d0d] transition-colors">
|
||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 text-xs font-bold font-body text-[#555]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#3b82f6] to-[#8b5cf6] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-white font-body">
|
||||
{u.author_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white font-body truncate">{u.author_name}</p>
|
||||
<p className="text-xs text-[#555] font-body">{u.threads} threads · {u.replies} replies</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-sm font-bold text-white font-body">{u.total}</p>
|
||||
<p className="text-xs text-[#555] font-body">posts</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Bar Chart */}
|
||||
{topCategories.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
||||
<SectionHeader
|
||||
title="Category Activity"
|
||||
subtitle="Thread and post distribution across categories"
|
||||
/>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={topCategories} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: '#555', fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '11px', color: '#888', paddingTop: '8px' }}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
/>
|
||||
<Bar dataKey="thread_count" fill="#3b82f6" radius={[3, 3, 0, 0]} name="Threads" />
|
||||
<Bar dataKey="post_count" fill="#10b981" radius={[3, 3, 0, 0]} name="Posts" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Moderation Stats */}
|
||||
<section className="mb-8">
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-display font-bold text-white">Moderation Stats</h2>
|
||||
<p className="text-xs text-[#555] font-body mt-0.5">Current moderation state across all forum content</p>
|
||||
</div>
|
||||
<Link href="/admin/forum-moderation" className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">Open moderation →</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 divide-x divide-y sm:divide-y-0 divide-[#1a1a1a]">
|
||||
{[
|
||||
{ label: 'Flagged Threads', value: moderation.flaggedThreads, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: 'Flagged Replies', value: moderation.flaggedReplies, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: 'Locked Threads', value: moderation.lockedThreads, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
{ label: 'Pinned Threads', value: moderation.pinnedThreads, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: 'Featured Threads', value: moderation.featuredThreads, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: 'Hidden Replies', value: moderation.hiddenReplies, color: 'text-[#888]', bg: 'bg-[#1a1a1a]' },
|
||||
].map(({ label, value, color, bg }) => (
|
||||
<div key={label} className="px-4 py-5 flex flex-col items-center gap-1 text-center">
|
||||
<p className={`text-2xl font-bold font-display ${color}`}>{value}</p>
|
||||
<p className="text-xs text-[#555] font-body leading-tight">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user