Problem: /admin/* pages redirect unauthed users to /login or /account,
both of which then hit middleware redirecting to distribute.relevant
mediaproperties.com. Distribute is on a different domain, so its session
cookie doesn't carry to broadcastbeat.com — sign-in there left admins
stranded on distribute's dashboard.
Fix:
- Remove /login (and /register) from middleware REDIRECT_PATHS. wp-login,
wp-admin, client-login still bounce to distribute.
- /login is now a real BB sign-in form (was a redirect to /client-login).
- Admin pages preserve return path via /login?next=<encoded path>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
585 lines
28 KiB
TypeScript
585 lines
28 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,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
Legend,
|
|
} from 'recharts';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface ArticleMetrics {
|
|
totalArticles: number;
|
|
publishedArticles: number;
|
|
draftArticles: number;
|
|
pendingArticles: number;
|
|
archivedArticles: number;
|
|
importedArticles: number;
|
|
nativeArticles: number;
|
|
}
|
|
|
|
interface AuthorStat {
|
|
author_name: string;
|
|
total: number;
|
|
published: number;
|
|
draft: number;
|
|
pending: number;
|
|
}
|
|
|
|
interface CategoryStat {
|
|
category: string;
|
|
count: number;
|
|
published: number;
|
|
}
|
|
|
|
interface ImportStat {
|
|
label: string;
|
|
total: number;
|
|
success: number;
|
|
failed: number;
|
|
rate: number;
|
|
}
|
|
|
|
interface MonthlyTrend {
|
|
month: string;
|
|
published: number;
|
|
imported: number;
|
|
native: number;
|
|
}
|
|
|
|
// ─── Mock / derived data helpers ──────────────────────────────────────────────
|
|
|
|
const CATEGORY_COLORS = [
|
|
'#3b82f6', '#f59e0b', '#10b981', '#8b5cf6',
|
|
'#ef4444', '#06b6d4', '#f97316', '#84cc16',
|
|
];
|
|
|
|
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: number | string;
|
|
sub?: string;
|
|
accent?: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: StatCardProps) {
|
|
return (
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex items-start gap-4">
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{ background: `${accent}18` }}
|
|
>
|
|
<span style={{ color: accent }}>{icon}</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">{label}</p>
|
|
<p className="text-white text-2xl font-bold font-heading leading-none">{value}</p>
|
|
{sub && <p className="text-[#555] text-xs font-body mt-1">{sub}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section Header ───────────────────────────────────────────────────────────
|
|
|
|
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
|
return (
|
|
<div className="mb-5">
|
|
<h2 className="text-white text-base font-bold font-heading uppercase tracking-wider">{title}</h2>
|
|
{subtitle && <p className="text-[#555] text-xs font-body mt-0.5">{subtitle}</p>}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function AdminAnalyticsPage() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [metrics, setMetrics] = useState<ArticleMetrics | null>(null);
|
|
const [authorStats, setAuthorStats] = useState<AuthorStat[]>([]);
|
|
const [categoryStats, setCategoryStats] = useState<CategoryStat[]>([]);
|
|
const [importStats, setImportStats] = useState<ImportStat[]>([]);
|
|
const [monthlyTrends, setMonthlyTrends] = useState<MonthlyTrend[]>([]);
|
|
const [loadingData, setLoadingData] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) router.replace('/login?next=' + encodeURIComponent(window.location.pathname));
|
|
}, [user, loading, router]);
|
|
|
|
const fetchAnalytics = useCallback(async () => {
|
|
setLoadingData(true);
|
|
setError(null);
|
|
try {
|
|
// Fetch all articles to derive analytics
|
|
const res = await fetch('/api/admin/articles');
|
|
if (!res.ok) throw new Error('Failed to fetch articles');
|
|
const data = await res.json();
|
|
const articles: any[] = data.articles ?? [];
|
|
|
|
// ── Article Metrics ──────────────────────────────────────────────────
|
|
const m: ArticleMetrics = {
|
|
totalArticles: articles.length,
|
|
publishedArticles: articles.filter((a) => a.status === 'published').length,
|
|
draftArticles: articles.filter((a) => a.status === 'draft').length,
|
|
pendingArticles: articles.filter((a) => a.status === 'pending').length,
|
|
archivedArticles: articles.filter((a) => a.status === 'archived').length,
|
|
importedArticles: articles.filter((a) => a.source === 'imported').length,
|
|
nativeArticles: articles.filter((a) => a.source === 'native').length,
|
|
};
|
|
setMetrics(m);
|
|
|
|
// ── Author Stats ─────────────────────────────────────────────────────
|
|
const authorMap: Record<string, AuthorStat> = {};
|
|
articles.forEach((a) => {
|
|
const name = a.author_name || 'Unknown';
|
|
if (!authorMap[name]) {
|
|
authorMap[name] = { author_name: name, total: 0, published: 0, draft: 0, pending: 0 };
|
|
}
|
|
authorMap[name].total++;
|
|
if (a.status === 'published') authorMap[name].published++;
|
|
if (a.status === 'draft') authorMap[name].draft++;
|
|
if (a.status === 'pending') authorMap[name].pending++;
|
|
});
|
|
const sortedAuthors = Object.values(authorMap)
|
|
.sort((a, b) => b.total - a.total)
|
|
.slice(0, 10);
|
|
setAuthorStats(sortedAuthors);
|
|
|
|
// ── Category Stats ───────────────────────────────────────────────────
|
|
const catMap: Record<string, CategoryStat> = {};
|
|
articles.forEach((a) => {
|
|
const cat = a.category || 'Uncategorized';
|
|
if (!catMap[cat]) catMap[cat] = { category: cat, count: 0, published: 0 };
|
|
catMap[cat].count++;
|
|
if (a.status === 'published') catMap[cat].published++;
|
|
});
|
|
const sortedCats = Object.values(catMap)
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 8);
|
|
setCategoryStats(sortedCats);
|
|
|
|
// ── Import Stats ─────────────────────────────────────────────────────
|
|
const imported = articles.filter((a) => a.source === 'imported');
|
|
const native = articles.filter((a) => a.source === 'native');
|
|
const importedPublished = imported.filter((a) => a.status === 'published').length;
|
|
const nativePublished = native.filter((a) => a.status === 'published').length;
|
|
setImportStats([
|
|
{
|
|
label: 'WP Imported',
|
|
total: imported.length,
|
|
success: importedPublished,
|
|
failed: imported.filter((a) => a.status === 'archived').length,
|
|
rate: imported.length > 0 ? Math.round((importedPublished / imported.length) * 100) : 0,
|
|
},
|
|
{
|
|
label: 'Native',
|
|
total: native.length,
|
|
success: nativePublished,
|
|
failed: native.filter((a) => a.status === 'archived').length,
|
|
rate: native.length > 0 ? Math.round((nativePublished / native.length) * 100) : 0,
|
|
},
|
|
]);
|
|
|
|
// ── Monthly Trends (last 6 months from article dates) ────────────────
|
|
const now = new Date();
|
|
const months: MonthlyTrend[] = [];
|
|
for (let i = 5; i >= 0; i--) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
const label = d.toLocaleString('default', { month: 'short', year: '2-digit' });
|
|
const monthArticles = articles.filter((a) => {
|
|
const date = a.date ? new Date(a.date) : null;
|
|
if (!date) return false;
|
|
return (
|
|
date.getFullYear() === d.getFullYear() && date.getMonth() === d.getMonth()
|
|
);
|
|
});
|
|
months.push({
|
|
month: label,
|
|
published: monthArticles.filter((a) => a.status === 'published').length,
|
|
imported: monthArticles.filter((a) => a.source === 'imported').length,
|
|
native: monthArticles.filter((a) => a.source === 'native').length,
|
|
});
|
|
}
|
|
setMonthlyTrends(months);
|
|
} catch (err: any) {
|
|
setError(err.message ?? 'Unknown error');
|
|
} finally {
|
|
setLoadingData(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (user) fetchAnalytics();
|
|
}, [user, fetchAnalytics]);
|
|
|
|
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 analytics…</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const publishRate = metrics && metrics.totalArticles > 0
|
|
? Math.round((metrics.publishedArticles / metrics.totalArticles) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
|
<div className="bg-[#111] border-b border-[#252525]">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-5 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-white text-xl font-bold font-heading uppercase tracking-wider">Analytics</h1>
|
|
<p className="text-[#555] text-xs font-body mt-0.5">
|
|
Article performance, author contributions & import health
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Link href="/admin/articles" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
|
Articles
|
|
</Link>
|
|
<span className="text-[#333]">·</span>
|
|
<Link href="/admin/users" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
|
Users
|
|
</Link>
|
|
<span className="text-[#333]">·</span>
|
|
<Link href="/admin/import" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
|
WP Import
|
|
</Link>
|
|
<button
|
|
onClick={fetchAnalytics}
|
|
className="ml-2 px-3 py-1.5 bg-[#1a1a1a] border border-[#333] hover:border-[#3b82f6] text-[#888] hover:text-[#3b82f6] text-xs font-body rounded transition-colors"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8 space-y-10">
|
|
|
|
{error && (
|
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm font-body">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Overview Stats ───────────────────────────────────────────── */}
|
|
<section>
|
|
<SectionHeader title="Article Performance" subtitle="Overall content health across all sources" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
<StatCard
|
|
label="Total Articles"
|
|
value={metrics?.totalArticles ?? 0}
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>}
|
|
accent="#3b82f6"
|
|
/>
|
|
<StatCard
|
|
label="Published"
|
|
value={metrics?.publishedArticles ?? 0}
|
|
sub={`${publishRate}% publish rate`}
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>}
|
|
accent="#10b981"
|
|
/>
|
|
<StatCard
|
|
label="Pending Review"
|
|
value={metrics?.pendingArticles ?? 0}
|
|
sub="Awaiting approval"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>}
|
|
accent="#f59e0b"
|
|
/>
|
|
<StatCard
|
|
label="Drafts"
|
|
value={metrics?.draftArticles ?? 0}
|
|
sub="In progress"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>}
|
|
accent="#8b5cf6"
|
|
/>
|
|
<StatCard
|
|
label="WP Imported"
|
|
value={metrics?.importedArticles ?? 0}
|
|
sub="From WordPress"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>}
|
|
accent="#06b6d4"
|
|
/>
|
|
<StatCard
|
|
label="Native Articles"
|
|
value={metrics?.nativeArticles ?? 0}
|
|
sub="Created in-platform"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>}
|
|
accent="#f97316"
|
|
/>
|
|
<StatCard
|
|
label="Archived"
|
|
value={metrics?.archivedArticles ?? 0}
|
|
sub="No longer active"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>}
|
|
accent="#555"
|
|
/>
|
|
<StatCard
|
|
label="Publish Rate"
|
|
value={`${publishRate}%`}
|
|
sub="Published / total"
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>}
|
|
accent="#3b82f6"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Monthly Trend ────────────────────────────────────────────── */}
|
|
<section>
|
|
<SectionHeader title="Monthly Trends" subtitle="Published, imported, and native articles over the last 6 months" />
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
|
{monthlyTrends.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<LineChart data={monthlyTrends} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
|
|
<XAxis dataKey="month" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Legend wrapperStyle={{ fontSize: 11, color: '#666' }} />
|
|
<Line type="monotone" dataKey="published" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} name="Published" />
|
|
<Line type="monotone" dataKey="imported" stroke="#06b6d4" strokeWidth={2} dot={{ r: 3, fill: '#06b6d4' }} name="Imported" />
|
|
<Line type="monotone" dataKey="native" stroke="#f97316" strokeWidth={2} dot={{ r: 3, fill: '#f97316' }} name="Native" />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<p className="text-[#444] text-sm font-body text-center py-10">No trend data available</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Author Contributions + Category Performance ──────────────── */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
{/* Author Contributions */}
|
|
<section>
|
|
<SectionHeader title="Author Contributions" subtitle="Top contributors by article count" />
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
|
{authorStats.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<BarChart
|
|
data={authorStats}
|
|
layout="vertical"
|
|
margin={{ top: 0, right: 8, left: 0, bottom: 0 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" horizontal={false} />
|
|
<XAxis type="number" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="author_name"
|
|
tick={{ fill: '#888', fontSize: 11 }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
width={100}
|
|
tickFormatter={(v: string) => v.length > 14 ? v.slice(0, 13) + '…' : v}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Bar dataKey="published" stackId="a" fill="#10b981" name="Published" radius={[0, 0, 0, 0]} />
|
|
<Bar dataKey="draft" stackId="a" fill="#f59e0b" name="Draft" />
|
|
<Bar dataKey="pending" stackId="a" fill="#f97316" name="Pending" radius={[0, 3, 3, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<p className="text-[#444] text-sm font-body text-center py-10">No author data available</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Top Categories */}
|
|
<section>
|
|
<SectionHeader title="Top-Performing Categories" subtitle="Article distribution by category" />
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
|
{categoryStats.length > 0 ? (
|
|
<>
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<PieChart>
|
|
<Pie
|
|
data={categoryStats}
|
|
dataKey="count"
|
|
nameKey="category"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={80}
|
|
innerRadius={44}
|
|
paddingAngle={2}
|
|
>
|
|
{categoryStats.map((_, i) => (
|
|
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
<div className="mt-4 space-y-2">
|
|
{categoryStats.map((cat, i) => (
|
|
<div key={cat.category} className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
style={{ background: CATEGORY_COLORS[i % CATEGORY_COLORS.length] }}
|
|
/>
|
|
<span className="text-[#888] text-xs font-body truncate">{cat.category}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
<span className="text-[#555] text-xs font-body">{cat.published} pub</span>
|
|
<span className="text-white text-xs font-bold font-body">{cat.count}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-[#444] text-sm font-body text-center py-10">No category data available</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* ── Import Success Rates ─────────────────────────────────────── */}
|
|
<section>
|
|
<SectionHeader title="Import Success Rates" subtitle="Publishing success across content sources" />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{importStats.map((stat) => (
|
|
<div key={stat.label} className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-white text-sm font-bold font-heading uppercase tracking-wider">{stat.label}</p>
|
|
<span
|
|
className="text-xs font-bold font-body px-2 py-0.5 rounded"
|
|
style={{
|
|
color: stat.rate >= 70 ? '#10b981' : stat.rate >= 40 ? '#f59e0b' : '#ef4444',
|
|
background: stat.rate >= 70 ? '#10b98118' : stat.rate >= 40 ? '#f59e0b18' : '#ef444418',
|
|
}}
|
|
>
|
|
{stat.rate}% success
|
|
</span>
|
|
</div>
|
|
{/* Progress bar */}
|
|
<div className="w-full h-2 bg-[#1e1e1e] rounded-full overflow-hidden mb-4">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-700"
|
|
style={{
|
|
width: `${stat.rate}%`,
|
|
background: stat.rate >= 70 ? '#10b981' : stat.rate >= 40 ? '#f59e0b' : '#ef4444',
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Total</p>
|
|
<p className="text-white text-lg font-bold font-heading">{stat.total}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Published</p>
|
|
<p className="text-[#10b981] text-lg font-bold font-heading">{stat.success}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Archived</p>
|
|
<p className="text-[#ef4444] text-lg font-bold font-heading">{stat.failed}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Author Detail Table ──────────────────────────────────────── */}
|
|
<section>
|
|
<SectionHeader title="Author Detail Breakdown" subtitle="Per-author article status distribution" />
|
|
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm font-body">
|
|
<thead>
|
|
<tr className="border-b border-[#1e1e1e]">
|
|
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Author</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Total</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Published</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Draft</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Pending</th>
|
|
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Pub Rate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{authorStats.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="text-center text-[#444] py-8">No author data available</td>
|
|
</tr>
|
|
) : (
|
|
authorStats.map((a, i) => {
|
|
const rate = a.total > 0 ? Math.round((a.published / a.total) * 100) : 0;
|
|
return (
|
|
<tr
|
|
key={a.author_name}
|
|
className={`border-b border-[#1a1a1a] hover:bg-[#151515] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
|
|
>
|
|
<td className="px-4 py-3 text-white font-medium">{a.author_name}</td>
|
|
<td className="px-4 py-3 text-right text-[#888]">{a.total}</td>
|
|
<td className="px-4 py-3 text-right text-[#10b981]">{a.published}</td>
|
|
<td className="px-4 py-3 text-right text-[#f59e0b]">{a.draft}</td>
|
|
<td className="px-4 py-3 text-right text-[#f97316]">{a.pending}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span
|
|
className="text-xs font-bold px-2 py-0.5 rounded"
|
|
style={{
|
|
color: rate >= 70 ? '#10b981' : rate >= 40 ? '#f59e0b' : '#ef4444',
|
|
background: rate >= 70 ? '#10b98118' : rate >= 40 ? '#f59e0b18' : '#ef444418',
|
|
}}
|
|
>
|
|
{rate}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|