- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
566 lines
26 KiB
TypeScript
566 lines
26 KiB
TypeScript
'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-[#DCE6F2] 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-[#FFFFFF] 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 = ['#1D4ED8', '#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?next=' + encodeURIComponent(window.location.pathname));
|
|
}, [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-[#1D4ED8] 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-[#DCE6F2] 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-[#1D4ED8] 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-[#DCE6F2] 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="#FFFFFF" />
|
|
<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="#1D4ED8" strokeWidth={2} dot={{ fill: '#1D4ED8', 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-[#DCE6F2] rounded-lg overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-[#FFFFFF] 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-[#1D4ED8] 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-[#FFFFFF]">
|
|
{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-[#F8FAFC] 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-[#FFFFFF] 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-[#DCE6F2] rounded-lg overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-[#FFFFFF]">
|
|
<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-[#FFFFFF]">
|
|
{activeUsers.map((u, idx) => (
|
|
<div key={u.author_id} className="px-5 py-3 flex items-center gap-3 hover:bg-[#F8FAFC] transition-colors">
|
|
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] 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-[#1D4ED8] 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-[#DCE6F2] 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="#FFFFFF" />
|
|
<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="#1D4ED8" 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-[#DCE6F2] rounded-lg overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-[#FFFFFF] 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-[#1D4ED8] 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-[#FFFFFF]">
|
|
{[
|
|
{ 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-[#FFFFFF]' },
|
|
].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>
|
|
);
|
|
}
|