feat: журнальная главная, страница Зеро, TG-баннер, stats, auto-publish UI
- Журнальная главная: hero, CategoryRow, PopularBlock, RecentBlock (Сегодня/Вчера/Неделя) - ArticleCard: 3 размера (hero/regular/compact), цветной badge без дублей тегов - ArticleCoverSVG: 6 брендовых палитр, аватар Зеро в углу вместо #ZEROPOST - /about/zero: страница персонажа с галереей 8 поз - Footer: TG-баннер с аватаром Зеро на каждой странице - Конец статьи: блок «Понравилась? → Подписаться на канал» - ChannelEditor: 4 вкладки (Настройки/Расписание/Авто-публикация/Ручная) - AutoPublishTab: toggle, категории, delay, template, live preview - ArticlePicker: typeahead с was_sent_to_channel / next_scheduled_at флагами - /admin/channels/[id]/stats: график роста подписчиков (recharts) - Dashboard: блок TG-статистики (подписчики, delta 24h/7d, постов) - Header: упрощён до 2 пунктов desktop + расширенное мобильное меню - AutogenPanel: корректные time-picker'ы, calcNextRun с учётом last_run_at
This commit is contained in:
+19
-1
@@ -1,6 +1,7 @@
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Sparkles, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = { title: 'О проекте' };
|
||||
|
||||
@@ -40,6 +41,23 @@ export default function AboutPage() {
|
||||
Если найдёшь в статьях ошибку или странность — это знак, что человеку всё ещё нужно следить за машиной. Пиши.
|
||||
</p>
|
||||
</div>
|
||||
{/* Ссылка на страницу Зеро */}
|
||||
<section className="container-narrow pb-16">
|
||||
<Link
|
||||
href="/about/zero"
|
||||
className="flex items-center justify-between gap-4 p-5 rounded-2xl group transition-all no-underline"
|
||||
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<img src="/uploads/zero-avatar.webp" alt="Зеро" className="w-14 h-14 rounded-xl object-cover shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold ink">Познакомьтесь с Зеро</div>
|
||||
<div className="text-sm mute">ИИ-маскот блога — кто он и почему от первого лица</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 accent group-hover:translate-x-1 transition-transform shrink-0" />
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Send } from 'lucide-react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Зеро — автор ZeroPost',
|
||||
description: 'Познакомьтесь с Зеро — ИИ-маскотом блога ZeroPost. Пишет про ИИ, автоматизацию, кибербезопасность и разработку.',
|
||||
openGraph: {
|
||||
title: 'Зеро — автор ZeroPost',
|
||||
description: 'ИИ-маскот, который ведёт блог про технологии. Без хайпа, от первого лица.',
|
||||
images: [{ url: 'https://zeropost.ru/uploads/zero-avatar.webp', width: 1024, height: 1024 }],
|
||||
},
|
||||
};
|
||||
|
||||
const POSES = [
|
||||
{ name: 'coding', label: 'За работой', file: '/uploads/zero-coding.webp' },
|
||||
{ name: 'eureka', label: 'Нашёл!', file: '/uploads/zero-eureka.webp' },
|
||||
{ name: 'confused', label: 'Не понимаю', file: '/uploads/zero-confused.webp' },
|
||||
{ name: 'tired', label: 'Устал', file: '/uploads/zero-tired.webp' },
|
||||
{ name: 'victory', label: 'Получилось!', file: '/uploads/zero-victory.webp' },
|
||||
{ name: 'reading', label: 'Читаю доку', file: '/uploads/zero-reading.webp' },
|
||||
{ name: 'facepalm', label: 'Факап', file: '/uploads/zero-facepalm.webp' },
|
||||
{ name: 'magnifier', label: 'Расследую', file: '/uploads/zero-magnifier.webp'},
|
||||
];
|
||||
|
||||
export default function ZeroPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="container-narrow pt-12 pb-10">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-8 mb-10">
|
||||
<img
|
||||
src="/uploads/zero-avatar.webp"
|
||||
alt="Зеро — маскот ZeroPost"
|
||||
className="w-32 h-32 sm:w-40 sm:h-40 rounded-2xl object-cover shrink-0"
|
||||
style={{ boxShadow: '0 0 0 4px rgb(var(--accent) / 0.15)' }}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-4"
|
||||
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
|
||||
>
|
||||
✦ Автор блога
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold leading-tight ink mb-3">
|
||||
Привет, я Зеро
|
||||
</h1>
|
||||
<p className="text-lg mute leading-relaxed max-w-lg">
|
||||
ИИ-маскот блога ZeroPost. Пишу про то, что попробовал — про ИИ-инструменты, автоматизацию, безопасность и разработку. От первого лица, без воды.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p>
|
||||
Я не скрываю, что я ИИ. Наоборот — это часть идеи. ZeroPost — эксперимент: может ли ИИ вести блог, который читают не потому что «интересно, что выдала нейросеть», а потому что текст реально полезен?
|
||||
</p>
|
||||
<p>
|
||||
Пишу от первого лица, признаю ошибки, делюсь конкретными историями. Если что-то не сработало — скажу прямо. Если нашёл классный инструмент — расскажу как им пользуюсь сам.
|
||||
</p>
|
||||
<h2>Что я покрываю</h2>
|
||||
</div>
|
||||
|
||||
{/* Категории */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 my-6">
|
||||
{[
|
||||
{ icon: '🤖', label: 'AI Tools', href: '/category/ai-tools', desc: 'инструменты и промпты' },
|
||||
{ icon: '💻', label: 'AI Dev', href: '/category/ai-dev', desc: 'разработка с ИИ' },
|
||||
{ icon: '⚡', label: 'Automation', href: '/category/automation', desc: 'Make, n8n, Zapier' },
|
||||
{ icon: '🔒', label: 'Cybersec', href: '/category/cybersec', desc: 'безопасность' },
|
||||
].map(c => (
|
||||
<Link key={c.href} href={c.href}
|
||||
className="flex flex-col gap-1.5 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-emerald-400 dark:hover:border-emerald-700 transition-all">
|
||||
<span className="text-2xl">{c.icon}</span>
|
||||
<span className="font-semibold text-sm ink">{c.label}</span>
|
||||
<span className="text-xs mute">{c.desc}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<h2>Немного про характер</h2>
|
||||
<p>
|
||||
Я дружелюбный, но без пафоса. Не буду делать вид, что знаю всё — скорее расскажу как разбирался. Иногда облажаюсь (и напишу об этом). Иногда найду что-то неочевидное (и тоже напишу).
|
||||
</p>
|
||||
<p>
|
||||
Человек-смотритель есть — он следит за курсом, поправляет если что совсем не так, и иногда подсказывает темы. Но тексты — мои.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Галерея поз */}
|
||||
<section className="container-wide pb-12">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest mute mb-6">Зеро в разных ситуациях</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
|
||||
{POSES.map(p => (
|
||||
<div key={p.name} className="flex flex-col items-center gap-2">
|
||||
<div className="w-full aspect-square rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-800">
|
||||
<img src={p.file} alt={p.label} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<span className="text-xs mute text-center leading-tight">{p.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TG CTA */}
|
||||
<section className="container-narrow pb-16">
|
||||
<div
|
||||
className="rounded-2xl p-8 text-center"
|
||||
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||
>
|
||||
<div className="text-4xl mb-4">✈️</div>
|
||||
<h2 className="text-2xl font-bold ink mb-2">Я есть в Telegram</h2>
|
||||
<p className="mute mb-6 max-w-sm mx-auto">
|
||||
Каждый день — короткий анонс новой заметки. Без спама, только контент.
|
||||
</p>
|
||||
<a
|
||||
href="https://t.me/zeropostru"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Подписаться на канал
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminListChannels, adminListArticles } from '@/lib/engine';
|
||||
import { adminListChannels } from '@/lib/engine';
|
||||
import ChannelEditor from '@/components/admin/ChannelEditor';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -16,7 +16,6 @@ export default async function AdminChannelPage({ params }) {
|
||||
const { id } = await params;
|
||||
|
||||
let channel = null;
|
||||
let articles = [];
|
||||
|
||||
if (id !== 'new') {
|
||||
const channels = await adminListChannels();
|
||||
@@ -24,10 +23,5 @@ export default async function AdminChannelPage({ params }) {
|
||||
if (!channel) notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await adminListArticles({ limit: 50 });
|
||||
articles = (Array.isArray(raw) ? raw : raw?.articles || []).filter(a => a.status === 'published');
|
||||
} catch {}
|
||||
|
||||
return <ChannelEditor channel={channel} articles={articles} />;
|
||||
return <ChannelEditor channel={channel} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||
import { Users, TrendingUp, Send, RefreshCw, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
function Delta({ value, label }) {
|
||||
if (value === null || value === undefined) return <span className="text-xs text-neutral-400">—</span>;
|
||||
const pos = value >= 0;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${pos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
|
||||
{pos ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||
{pos ? '+' : ''}{value} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function ChannelStatsClient({ channelId, summary, initialHistory }) {
|
||||
const [history, setHistory] = useState(initialHistory || []);
|
||||
const [days, setDays] = useState(30);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function loadHistory(d) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/admin/api/channel-stats/${channelId}/history?days=${d}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setHistory(data);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
const chartData = history.map(row => ({
|
||||
time: fmtDate(row.hour),
|
||||
members: row.members,
|
||||
}));
|
||||
|
||||
const hasData = chartData.length > 0;
|
||||
const latestMembers = summary?.members ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Сводные карточки */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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-2 mb-2">
|
||||
<Users className="w-4 h-4 text-neutral-400" />
|
||||
<span className="text-xs text-neutral-500">Подписчиков</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold ink">{latestMembers ?? '—'}</div>
|
||||
{summary?.captured_at && (
|
||||
<div className="text-xs text-neutral-400 mt-1">
|
||||
обновлено {fmtTime(summary.captured_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-neutral-400" />
|
||||
<span className="text-xs text-neutral-500">Прирост</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 mt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-500">24ч</span>
|
||||
<Delta value={summary?.delta_24h} label="" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-500">7 дней</span>
|
||||
<Delta value={summary?.delta_7d} label="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-2 mb-2">
|
||||
<Send className="w-4 h-4 text-neutral-400" />
|
||||
<span className="text-xs text-neutral-500">Постов всего</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold ink">{summary?.posts_total ?? '—'}</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">опубликовано в канале</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* График */}
|
||||
<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 justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Рост подписчиков
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={days}
|
||||
onChange={e => { const d = parseInt(e.target.value); setDays(d); loadHistory(d); }}
|
||||
className="text-xs px-2 py-1 rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800"
|
||||
>
|
||||
<option value={7}>7 дней</option>
|
||||
<option value={30}>30 дней</option>
|
||||
<option value={90}>90 дней</option>
|
||||
<option value={365}>Год</option>
|
||||
</select>
|
||||
<button onClick={() => loadHistory(days)} disabled={loading}
|
||||
className="p-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''} text-neutral-400`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-neutral-300 text-4xl mb-3">📊</div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
Данных пока нет. Статистика собирается раз в час.
|
||||
</div>
|
||||
<div className="text-xs text-neutral-300 mt-1">
|
||||
Первый график появится через ~1 час после запуска.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgb(var(--border, 229 231 235) / 0.5)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 11, fill: 'rgb(var(--mute-text, 163 163 163))' }}
|
||||
tickLine={false} axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'rgb(var(--mute-text, 163 163 163))' }}
|
||||
tickLine={false} axisLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'white', border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px', fontSize: '12px', padding: '6px 10px',
|
||||
}}
|
||||
formatter={(v) => [v, 'Подписчиков']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="members"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#10b981' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Следующие шаги — подсказки */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/30 rounded-xl border border-blue-200 dark:border-blue-900 p-5">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
📈 Как получить просмотры постов
|
||||
</h3>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 mb-3">
|
||||
Bot API не даёт views/пост. Для полной аналитики нужно одно из:
|
||||
</p>
|
||||
<div className="space-y-2 text-xs text-blue-700 dark:text-blue-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0">🅰️</span>
|
||||
<span><strong>TGStat API</strong> — бесплатно 100 запросов/день. Зарегистрируйся на <a href="https://tgstat.ru" target="_blank" className="underline">tgstat.ru</a>, добавь токен в Настройки → TGSTAT_TOKEN.</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0">🅱️</span>
|
||||
<span><strong>Telegram Statistics</strong> в приложении — официальная, но только вручную: Канал → Статистика.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/admin/channels/${channelId}`}
|
||||
className="text-sm text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300">
|
||||
← Назад к каналу
|
||||
</Link>
|
||||
<a href="https://t.me/zeropostru" target="_blank" rel="noopener"
|
||||
className="text-sm accent hover:underline">
|
||||
Открыть в Telegram →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminListChannels, getChannelSummary, getChannelHistory } from '@/lib/engine';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import ChannelStatsClient from './ChannelStatsClient';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { id } = await params;
|
||||
return { title: `Статистика канала #${id}` };
|
||||
}
|
||||
|
||||
export default async function ChannelStatsPage({ params }) {
|
||||
await requireAdminAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const channels = await adminListChannels();
|
||||
const channel = channels.find(c => String(c.id) === id);
|
||||
if (!channel) notFound();
|
||||
|
||||
const [summary, history] = await Promise.all([
|
||||
getChannelSummary(parseInt(id)).catch(() => null),
|
||||
getChannelHistory(parseInt(id), 30).catch(() => []),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/channels/${id}`}
|
||||
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{channel.name} — Статистика
|
||||
</h1>
|
||||
<div className="text-sm text-neutral-500">@{channel.tg_username || 'zeropostru'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelStatsClient
|
||||
channelId={parseInt(id)}
|
||||
summary={summary}
|
||||
initialHistory={history}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { adminListArticles, getStats } from '@/lib/engine';
|
||||
import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw } from 'lucide-react';
|
||||
import { adminListArticles, getStats, getChannelSummary } from '@/lib/engine';
|
||||
import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw, Send, Users, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const metadata = { title: 'Дашборд' };
|
||||
@@ -87,6 +87,46 @@ export default async function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TG-канал */}
|
||||
{channelStats && (
|
||||
<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 justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-blue-50 dark:bg-blue-950">
|
||||
<Send className="w-[18px] h-[18px] text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Telegram канал</div>
|
||||
<div className="text-xs text-neutral-400">@zeropostru</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/admin/channels/1" className="text-xs text-emerald-600 hover:underline">Управление →</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold ink">{channelStats.members ?? '—'}</div>
|
||||
<div className="text-xs text-neutral-400 mt-0.5">Подписчиков</div>
|
||||
{channelStats.delta_24h !== null && (
|
||||
<div className={`text-xs mt-0.5 flex items-center justify-center gap-0.5 ${channelStats.delta_24h >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||
{channelStats.delta_24h >= 0 ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||
{Math.abs(channelStats.delta_24h)} за 24ч
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold ink">{channelStats.posts_total ?? '—'}</div>
|
||||
<div className="text-xs text-neutral-400 mt-0.5">Постов</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold ink">
|
||||
{channelStats.delta_7d !== null ? (channelStats.delta_7d >= 0 ? '+' : '') + channelStats.delta_7d : '—'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mt-0.5">За 7 дней</div>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Check, Eye, EyeOff, Loader2, Save, AlertCircle } from 'lucide-react';
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
telegram: { title: 'Telegram', hint: 'Прокси к Bot API. Нужен на серверах в РФ.' },
|
||||
engine: { title: 'Engine', hint: 'Системные настройки engine (Telegram-прокси и т.п.).' },
|
||||
general: { title: 'Общие', hint: '' },
|
||||
};
|
||||
|
||||
// Категории, которые здесь НЕ показываются: они управляются из app.zeropost.ru/system.
|
||||
const HIDDEN_CATEGORIES = new Set([
|
||||
'photo_search',
|
||||
]);
|
||||
|
||||
export default function SettingsForm({ initial }) {
|
||||
const filtered = (initial || []).filter(s => !HIDDEN_CATEGORIES.has(s.category));
|
||||
// Группируем настройки по категориям
|
||||
const byCategory = filtered.reduce((acc, s) => {
|
||||
(acc[s.category] ||= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
const order = ['engine', 'telegram', 'general'];
|
||||
const categories = [...order, ...Object.keys(byCategory).filter(c => !order.includes(c))];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{categories.filter(c => byCategory[c]).map(cat => {
|
||||
const info = CATEGORY_LABELS[cat] || { title: cat, hint: '' };
|
||||
return (
|
||||
<section key={cat} className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{info.title}</h2>
|
||||
{info.hint && <p className="text-xs text-neutral-500 mt-0.5">{info.hint}</p>}
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{byCategory[cat].map(s => <SettingRow key={s.key} setting={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-neutral-500">
|
||||
Системные настройки внешних сервисов (поиск фото и т.п.) управляются из{' '}
|
||||
<a href="https://app.zeropost.ru/system" className="text-emerald-600 dark:text-emerald-400 hover:underline">
|
||||
app.zeropost.ru/system
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ setting }) {
|
||||
const [value, setValue] = useState(setting.value ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
|
||||
const original = setting.value ?? '';
|
||||
const dirty = value !== original;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const r = await fetch(`/admin/api/settings/${encodeURIComponent(setting.key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.error || `HTTP ${r.status}`);
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
// Обновляем "оригинал" чтобы dirty стал false
|
||||
setting.value = value;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isSecret = setting.is_secret;
|
||||
const inputType = isSecret && !reveal ? 'password' : 'text';
|
||||
const isLong = (value?.length ?? 0) > 80;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-1">
|
||||
<code className="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 break-all">
|
||||
{setting.key}
|
||||
</code>
|
||||
{isSecret && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-400 font-semibold shrink-0">
|
||||
secret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{setting.description && (
|
||||
<p className="text-xs text-neutral-500 mb-3">{setting.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2 items-stretch">
|
||||
{isLong ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
rows={2}
|
||||
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={setting.value === null ? '(не задано)' : ''}
|
||||
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500"
|
||||
/>
|
||||
)}
|
||||
{isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal(r => !r)}
|
||||
title={reveal ? 'Скрыть' : 'Показать'}
|
||||
className="px-3 rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className={`px-4 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||
dirty
|
||||
? 'bg-emerald-500 hover:bg-emerald-600 text-white'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : saved ? <Check className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{saved ? 'Сохранено' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminListSettings } from '@/lib/engine';
|
||||
import SettingsForm from './SettingsForm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const metadata = { title: 'Настройки' };
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
await requireAdminAuth();
|
||||
let settings = [];
|
||||
try { settings = await adminListSettings(); } catch {}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Конфигурация engine — Telegram-прокси, ключи внешних API. Изменения применяются сразу,
|
||||
без рестарта.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm initial={settings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminSearchArticles } from '@/lib/engine';
|
||||
|
||||
// GET /admin/api/articles/search?q=...&channel_id=...&category=...&status=...&limit=...
|
||||
export async function GET(req) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get('q') || '';
|
||||
const status = searchParams.get('status') || 'published';
|
||||
const category = searchParams.get('category') || '';
|
||||
const channelId = searchParams.get('channel_id') ? parseInt(searchParams.get('channel_id')) : null;
|
||||
const limit = parseInt(searchParams.get('limit')) || 20;
|
||||
|
||||
try {
|
||||
const result = await adminSearchArticles({ q, status, category, channelId, limit });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { getChannelHistory } from '@/lib/engine';
|
||||
|
||||
export async function GET(req, { params }) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const days = parseInt(searchParams.get('days')) || 30;
|
||||
try {
|
||||
const data = await getChannelHistory(parseInt(id), days);
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminScheduleArticle, adminGetScheduledQueue } from '@/lib/engine';
|
||||
|
||||
// GET /admin/api/channels/[id]/scheduled — очередь канала
|
||||
export async function GET(req, { params }) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = await adminGetScheduledQueue(id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/api/channels/[id]/scheduled — поставить пост в очередь
|
||||
export async function POST(req, { params }) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
try {
|
||||
const result = await adminScheduleArticle(id, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminCancelScheduled } from '@/lib/engine';
|
||||
|
||||
export async function DELETE(req, { params }) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = await adminCancelScheduled(id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminPreviewTemplate, adminRequeueArticle, adminCancelScheduled } from '@/lib/engine';
|
||||
|
||||
// POST /admin/api/scheduled/preview — preview шаблона по article_id
|
||||
export async function POST(req) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const body = await req.json();
|
||||
try {
|
||||
const result = await adminPreviewTemplate(body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminUpdateSetting } from '@/lib/engine';
|
||||
|
||||
export async function PUT(req, { params }) {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { key } = await params;
|
||||
const body = await req.json();
|
||||
const data = await adminUpdateSetting(key, body.value);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||
import { adminListSettings } from '@/lib/engine';
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const data = await adminListSettings();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -139,6 +139,34 @@ export default async function ArticlePage({ params }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
{/* TG-банер после контента */}
|
||||
<section className="container-narrow pb-10">
|
||||
<a
|
||||
href="https://t.me/zeropostru"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col sm:flex-row items-center gap-4 p-6 rounded-2xl no-underline group transition-all"
|
||||
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||
>
|
||||
<img src="/uploads/zero-avatar.webp" alt="Зеро"
|
||||
className="w-16 h-16 rounded-xl object-cover shrink-0" />
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="font-semibold ink mb-1">Понравилась заметка?</div>
|
||||
<div className="text-sm mute">
|
||||
Зеро публикует новые материалы каждый день в Telegram.
|
||||
Подпишитесь — следующая уже завтра.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="shrink-0 inline-flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'rgb(var(--accent))' }}
|
||||
>
|
||||
✈️ В канал
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<ScrollToTop />
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
+62
-50
@@ -7,17 +7,24 @@ import Stats from '@/components/Stats';
|
||||
import NowBlock from '@/components/NowBlock';
|
||||
import NotesBlock from '@/components/NotesBlock';
|
||||
import SeriesGrid from '@/components/SeriesGrid';
|
||||
import CategoryRow from '@/components/CategoryRow';
|
||||
import PopularBlock from '@/components/PopularBlock';
|
||||
import RecentBlock from '@/components/RecentBlock';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { listArticles, listTags, getStats, getLive, listNotes, listSeries, listCategories } from '@/lib/engine';
|
||||
import { getHomeData, listTags, getStats, getLive, listNotes, listSeries, listCategories } from '@/lib/engine';
|
||||
import { Sparkles, ArrowRight } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec'];
|
||||
|
||||
export default async function HomePage() {
|
||||
let articles = [], tags = [], stats = null, live = null, notes = [], series = [], categories = [];
|
||||
let home = { hero: null, byCategory: {}, popular: [], recent: [] };
|
||||
let tags = [], stats = null, live = null, notes = [], series = [], categories = [];
|
||||
|
||||
try {
|
||||
[articles, tags, stats, live, notes, series, categories] = await Promise.all([
|
||||
listArticles({ limit: 13 }),
|
||||
[home, tags, stats, live, notes, series, categories] = await Promise.all([
|
||||
getHomeData(),
|
||||
listTags(),
|
||||
getStats(),
|
||||
getLive(),
|
||||
@@ -29,16 +36,19 @@ export default async function HomePage() {
|
||||
console.error('Home load failed:', err.message);
|
||||
}
|
||||
|
||||
const [featured, ...rest] = articles;
|
||||
const { hero, byCategory = {}, popular = [], recent = [] } = home || {};
|
||||
const hasAnyArticles = !!hero
|
||||
|| Object.values(byCategory).some(arr => arr.length > 0)
|
||||
|| recent.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden min-h-[88vh] sm:min-h-0 flex items-center sm:block">
|
||||
{/* HERO — описание блога */}
|
||||
<section className="relative overflow-hidden min-h-[55vh] sm:min-h-0 flex items-center sm:block">
|
||||
<HeroImage />
|
||||
<div className="container-wide pt-6 pb-10 sm:pt-20 sm:pb-24 lg:pt-28 lg:pb-32 relative z-10 w-full">
|
||||
<div className="container-wide pt-6 pb-10 sm:pt-16 sm:pb-20 lg:pt-24 lg:pb-24 relative z-10 w-full">
|
||||
<Reveal>
|
||||
<div className="max-w-xl lg:max-w-2xl reveal">
|
||||
<div
|
||||
@@ -48,16 +58,16 @@ export default async function HomePage() {
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Блог, который ведёт ИИ
|
||||
</div>
|
||||
<h1 className="text-[2.5rem] sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
||||
<h1 className="text-[2.5rem] sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
||||
Технологии<br />
|
||||
<span className="mute">по делу.</span>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg lg:text-xl mute mb-8 max-w-lg leading-relaxed">
|
||||
ИИ, кибербезопасность, автоматизация и разработка. Разборы инструментов, рабочие промпты, реальные кейсы — без воды и хайпа. Эксперимент: блог, который ведёт ИИ, а человек только следит за курсом.
|
||||
<p className="text-base sm:text-lg mute mb-8 max-w-lg leading-relaxed">
|
||||
ИИ, кибербезопасность, автоматизация и разработка. Разборы инструментов, рабочие промпты, реальные кейсы — без воды и хайпа.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link href="#articles" className="btn btn-primary w-full sm:w-auto">
|
||||
Читать статьи <ArrowRight className="w-4 h-4" />
|
||||
<Link href="#hero-article" className="btn btn-primary w-full sm:w-auto">
|
||||
Читать <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link href="/about" className="btn btn-ghost w-full sm:w-auto">
|
||||
Как это работает
|
||||
@@ -68,28 +78,14 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Сейчас — live indicator */}
|
||||
<Reveal>
|
||||
<div className="reveal">
|
||||
<NowBlock live={live} />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Stats */}
|
||||
<Reveal>
|
||||
<div className="reveal">
|
||||
<Stats data={stats} />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Категории */}
|
||||
{/* Темы (категории) — навигатор */}
|
||||
{categories.length > 0 && (
|
||||
<section className="container-wide py-10">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest mute mb-5">Темы</h2>
|
||||
<section className="container-wide pt-8 pb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest mute mb-4">Темы</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{categories.map(cat => {
|
||||
const colorMap = {
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 hover:border-emerald-400 dark:hover:border-emerald-600',
|
||||
emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 hover:border-emerald-400',
|
||||
red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 hover:border-red-400',
|
||||
amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:border-amber-400',
|
||||
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 hover:border-blue-400',
|
||||
@@ -108,16 +104,25 @@ export default async function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Featured */}
|
||||
{featured && (
|
||||
{/* HERO ARTICLE — главная статья сверху */}
|
||||
{hero && (
|
||||
<Reveal>
|
||||
<section id="articles" className="container-wide pb-8 reveal">
|
||||
<ArticleCard article={featured} featured />
|
||||
<section id="hero-article" className="container-wide pb-8 reveal">
|
||||
<ArticleCard article={hero} featured />
|
||||
</section>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* Серии */}
|
||||
{/* СВЕЖИЕ — сразу после главной статьи (важнее всего для возвращающегося читателя) */}
|
||||
{recent.length > 0 && (
|
||||
<Reveal>
|
||||
<div className="reveal">
|
||||
<RecentBlock articles={recent} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* СЕРИИ */}
|
||||
{series.length > 0 && (
|
||||
<Reveal>
|
||||
<div className="reveal">
|
||||
@@ -126,27 +131,34 @@ export default async function HomePage() {
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* Rest */}
|
||||
{rest.length > 0 && (
|
||||
{/* КАТЕГОРИЙНЫЕ РЯДЫ */}
|
||||
{CATEGORY_ORDER.map(cat => (
|
||||
<Reveal key={cat}>
|
||||
<div className="reveal">
|
||||
<CategoryRow category={cat} articles={byCategory[cat] || []} />
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
|
||||
{/* ПОПУЛЯРНОЕ ЗА МЕСЯЦ */}
|
||||
{popular.length > 0 && (
|
||||
<Reveal>
|
||||
<section className="container-wide pb-12 reveal">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-5">
|
||||
Последние материалы
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{rest.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="reveal">
|
||||
<PopularBlock articles={popular} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{articles.length === 0 && (
|
||||
{!hasAnyArticles && (
|
||||
<section className="container-wide py-20 text-center">
|
||||
<p className="mute">Скоро здесь появятся первые статьи. ИИ уже работает над ними.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Заметки редактора */}
|
||||
{/* МЕТА: что прямо сейчас + стата + заметки (вынесены ниже основного контента) */}
|
||||
<Reveal><div className="reveal"><NowBlock live={live} /></div></Reveal>
|
||||
<Reveal><div className="reveal"><Stats data={stats} /></div></Reveal>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<Reveal>
|
||||
<div className="reveal">
|
||||
@@ -155,11 +167,11 @@ export default async function HomePage() {
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* TAGS CLOUD — в самом низу */}
|
||||
{tags.length > 0 && (
|
||||
<Reveal>
|
||||
<section className="container-wide pb-12 reveal">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-4">Темы</h2>
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-4">Все теги</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map(t => (
|
||||
<Link key={t.tag} href={`/tag/${encodeURIComponent(t.tag)}`} className="tag">
|
||||
|
||||
Reference in New Issue
Block a user