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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user