Files
zeropost-web/app/admin/(protected)/page.js
T

157 lines
7.4 KiB
JavaScript

import Link from 'next/link';
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() {
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>
);
}