159 lines
7.5 KiB
JavaScript
159 lines
7.5 KiB
JavaScript
import Link from 'next/link';
|
|
import { requireAdminAuth } from '@/lib/adminAuth';
|
|
import { adminListArticles, getStats } from '@/lib/engine';
|
|
import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw } from 'lucide-react';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
export const metadata = { title: 'Дашборд' };
|
|
|
|
function StatCard({ label, value, icon: Icon, color = 'emerald' }) {
|
|
const colors = {
|
|
emerald: 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400',
|
|
blue: 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400',
|
|
amber: 'bg-amber-50 dark:bg-amber-950 text-amber-600 dark:text-amber-400',
|
|
};
|
|
return (
|
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${colors[color]}`}>
|
|
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
|
</div>
|
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">{label}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{value ?? '—'}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function AdminDashboard() {
|
|
await requireAdminAuth();
|
|
|
|
const [articles, stats] = await Promise.allSettled([
|
|
adminListArticles({ limit: 50 }),
|
|
getStats(),
|
|
]);
|
|
|
|
const arts = articles.status === 'fulfilled' ? (Array.isArray(articles.value) ? articles.value : articles.value?.articles || []) : [];
|
|
const st = stats.status === 'fulfilled' ? stats.value : null;
|
|
|
|
const published = arts.filter(a => a.status === 'published').length;
|
|
const drafts = arts.filter(a => a.status === 'draft').length;
|
|
const withoutCover = arts.filter(a => !a.cover_url).length;
|
|
const recentArts = arts.slice(0, 8);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Дашборд</h1>
|
|
<p className="text-sm text-neutral-500 mt-0.5">Управление контентом zeropost.ru</p>
|
|
</div>
|
|
<Link
|
|
href="/admin/articles/new"
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Новая статья
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Статистика */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard label="Опубликовано" value={published} icon={FileText} color="emerald" />
|
|
<StatCard label="Черновики" value={drafts} icon={FileText} color="amber" />
|
|
<StatCard label="Просмотры (всего)" value={st?.total_views ?? '—'} icon={Eye} color="blue" />
|
|
<StatCard label="Без обложки" value={withoutCover} icon={Image} color={withoutCover > 0 ? 'amber' : 'emerald'} />
|
|
</div>
|
|
|
|
{/* Быстрые действия */}
|
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-4">Быстрые действия</h2>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Link
|
|
href="/admin/articles/new"
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4 text-emerald-500" />
|
|
Написать статью
|
|
</Link>
|
|
<Link
|
|
href="/admin/articles"
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
|
>
|
|
<FileText className="w-4 h-4 text-blue-500" />
|
|
Все статьи
|
|
</Link>
|
|
{withoutCover > 0 && (
|
|
<BackfillButton count={withoutCover} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Последние статьи */}
|
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
|
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Последние статьи</h2>
|
|
<Link href="/admin/articles" className="text-xs text-emerald-600 dark:text-emerald-400 hover:underline">
|
|
Все →
|
|
</Link>
|
|
</div>
|
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
{recentArts.map(a => (
|
|
<Link
|
|
key={a.id}
|
|
href={`/admin/articles/${a.id}`}
|
|
className="flex items-center gap-4 px-5 py-3.5 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors group"
|
|
>
|
|
{/* Обложка-превью */}
|
|
<div className="w-14 h-9 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 shrink-0">
|
|
{a.cover_url ? (
|
|
<img src={a.cover_url} alt="" className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Image className="w-4 h-4 text-neutral-300" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
|
|
{a.title}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
a.status === 'published'
|
|
? 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400'
|
|
: 'bg-amber-50 dark:bg-amber-950 text-amber-600 dark:text-amber-400'
|
|
}`}>
|
|
{a.status === 'published' ? 'Опубликовано' : 'Черновик'}
|
|
</span>
|
|
{a.tags?.slice(0, 2).map(t => (
|
|
<span key={t} className="text-xs text-neutral-400">#{t}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-neutral-400 shrink-0">
|
|
{a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) : '—'}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Кнопка backfill — клиентская
|
|
function BackfillButton({ count }) {
|
|
return (
|
|
<form action="/admin/api/backfill" method="POST">
|
|
<Link
|
|
href="/admin/articles?backfill=1"
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950 transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Догенерировать обложки ({count})
|
|
</Link>
|
|
</form>
|
|
);
|
|
}
|