feat: серии + count-up в Stats

- SeriesGrid: карточки серий с иконками (Sparkles/Plug/Zap/Layers) и цветовыми темами
- /series/[slug]: страница серии с интро и сеткой статей в порядке из article_ids
- Stats: count-up анимация (easeOutQuart 1.2s) при появлении в viewport через IntersectionObserver
- sitemap.xml: добавлены /notes и все серии
This commit is contained in:
Alexey Pavlov
2026-05-31 10:10:18 +03:00
parent c27985614e
commit 03c10eab6e
6 changed files with 232 additions and 20 deletions
+14 -3
View File
@@ -6,21 +6,23 @@ import HeroImage from '@/components/HeroImage';
import Stats from '@/components/Stats';
import NowBlock from '@/components/NowBlock';
import NotesBlock from '@/components/NotesBlock';
import SeriesGrid from '@/components/SeriesGrid';
import Reveal from '@/components/Reveal';
import { listArticles, listTags, getStats, getLive, listNotes } from '@/lib/engine';
import { listArticles, listTags, getStats, getLive, listNotes, listSeries } from '@/lib/engine';
import { Sparkles, ArrowRight } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default async function HomePage() {
let articles = [], tags = [], stats = null, live = null, notes = [];
let articles = [], tags = [], stats = null, live = null, notes = [], series = [];
try {
[articles, tags, stats, live, notes] = await Promise.all([
[articles, tags, stats, live, notes, series] = await Promise.all([
listArticles({ limit: 13 }),
listTags(),
getStats(),
getLive(),
listNotes({ limit: 6 }),
listSeries(),
]);
} catch (err) {
console.error('Home load failed:', err.message);
@@ -88,6 +90,15 @@ export default async function HomePage() {
</Reveal>
)}
{/* Серии */}
{series.length > 0 && (
<Reveal>
<div className="reveal">
<SeriesGrid series={series} />
</div>
</Reveal>
)}
{/* Rest */}
{rest.length > 0 && (
<Reveal>
+83
View File
@@ -0,0 +1,83 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import ArticleCard from '@/components/ArticleCard';
import { getSeries } from '@/lib/engine';
import { Sparkles, Plug, Zap, Layers, ArrowLeft } from 'lucide-react';
const ICONS = { Sparkles, Plug, Zap, Layers };
const COLORS = {
emerald: { bg: 'rgb(16 185 129 / 0.1)', text: '#10b981' },
teal: { bg: 'rgb(20 184 166 / 0.1)', text: '#14b8a6' },
amber: { bg: 'rgb(245 158 11 / 0.1)', text: '#f59e0b' },
indigo: { bg: 'rgb(99 102 241 / 0.1)', text: '#6366f1' },
};
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }) {
const { slug } = await params;
const s = await getSeries(slug);
if (!s) return { title: 'Серия не найдена' };
return {
title: s.title,
description: s.intro,
};
}
export default async function SeriesPage({ params }) {
const { slug } = await params;
const s = await getSeries(slug);
if (!s) notFound();
const Icon = ICONS[s.icon] || Layers;
const color = COLORS[s.color] || COLORS.emerald;
return (
<>
<Header />
<main className="pt-8 pb-16">
<div className="container-wide mb-8">
<Link href="/" className="btn btn-ghost text-sm mb-6 -ml-2">
<ArrowLeft className="w-4 h-4" /> Все статьи
</Link>
<div className="flex items-center gap-3 mb-4">
<div
className="w-12 h-12 sm:w-14 sm:h-14 rounded-xl flex items-center justify-center"
style={{ background: color.bg }}
>
<Icon className="w-6 h-6 sm:w-7 sm:h-7" style={{ color: color.text }} />
</div>
<div className="text-xs mute uppercase tracking-widest">Серия</div>
</div>
<h1 className="text-3xl sm:text-5xl font-bold ink leading-tight mb-3">
{s.title}
</h1>
{s.intro && (
<p className="mute text-base sm:text-lg max-w-2xl leading-relaxed">{s.intro}</p>
)}
<div className="text-xs mute mt-4">
{s.articles?.length || 0} {(s.articles?.length || 0) === 1 ? 'материал' : 'материалов'} в серии
</div>
</div>
{s.articles?.length > 0 ? (
<div className="container-wide">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{s.articles.map(a => <ArticleCard key={a.id} article={a} />)}
</div>
</div>
) : (
<div className="container-wide">
<div className="article-card p-8 text-center mute">
Скоро здесь появятся статьи. Серия только формируется.
</div>
</div>
)}
</main>
<Footer />
</>
);
}
+12 -3
View File
@@ -1,16 +1,18 @@
import { listArticles, listTags } from '@/lib/engine';
import { listArticles, listTags, listSeries } from '@/lib/engine';
const SITE = 'https://zeropost.ru';
export default async function sitemap() {
const [articles, tags] = await Promise.all([
const [articles, tags, series] = await Promise.all([
listArticles({ limit: 200 }).catch(() => []),
listTags().catch(() => []),
listSeries().catch(() => []),
]);
const staticPages = [
{ url: `${SITE}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
{ url: `${SITE}/notes`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.6 },
];
const articlePages = articles.map(a => ({
@@ -27,5 +29,12 @@ export default async function sitemap() {
priority: 0.6,
}));
return [...staticPages, ...articlePages, ...tagPages];
const seriesPages = series.map(s => ({
url: `${SITE}/series/${s.slug}`,
lastModified: s.updated_at ? new Date(s.updated_at) : new Date(),
changeFrequency: 'weekly',
priority: 0.7,
}));
return [...staticPages, ...articlePages, ...seriesPages, ...tagPages];
}