feat: мобильная версия + поиск + SEO-инфраструктура
Мобилка: - Header: hide-on-scroll, мобильный burger-menu, тонкая адаптация - Hero: текст и кнопки оптимизированы под узкие экраны (full-width buttons) - ArticleCard featured: на мобилке в столбик, картинка сверху - Stats: компактная сетка 2x2 с уменьшенным шрифтом - Глобально: scroll-behavior smooth, safe-area-inset, tap targets 40px+ - prefers-reduced-motion respected Страница статьи: - ReadingProgress: прогресс-бар сверху при скролле - ScrollToTop: круглая кнопка наверху после 800px скролла - ShareButton: Web Share API на мобилках, копирование URL на десктопе - Related articles: подбираем по пересечению тегов (max 3) - Мобильная типографика: prose-base sm:prose-lg, leading-relaxed SEO/инфра: - /api/search: простой поиск по title/excerpt/tags с подсветкой и скорингом - SearchBox: оверлей с / хоткеем, дебаунс 250ms, мобиле-friendly - /rss.xml: полноценный RSS-фид - sitemap.xml: динамический через next sitemap() - robots.txt: динамический - viewport metadata + theme-color для светлой/тёмной темы - alternates rel=alternate type=application/rss+xml
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { listArticles } from '@/lib/engine';
|
||||
|
||||
// GET /api/search?q=foo — простой поиск по title/excerpt/tags
|
||||
export async function GET(req) {
|
||||
const q = (req.nextUrl.searchParams.get('q') || '').trim().toLowerCase();
|
||||
if (!q || q.length < 2) return NextResponse.json([]);
|
||||
|
||||
try {
|
||||
const all = await listArticles({ limit: 100 });
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
|
||||
const matched = all
|
||||
.map(a => {
|
||||
const haystack = [a.title, a.excerpt, (a.tags || []).join(' ')]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
const score = tokens.reduce((acc, t) => acc + (haystack.includes(t) ? 1 : 0), 0);
|
||||
return { ...a, score };
|
||||
})
|
||||
.filter(a => a.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 12);
|
||||
|
||||
return NextResponse.json(matched);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+52
-12
@@ -2,7 +2,11 @@ import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { getArticle } from '@/lib/engine';
|
||||
import ReadingProgress from '@/components/ReadingProgress';
|
||||
import ScrollToTop from '@/components/ScrollToTop';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import ArticleCard from '@/components/ArticleCard';
|
||||
import { getArticle, listArticles } from '@/lib/engine';
|
||||
import { renderMarkdown, formatDate } from '@/lib/markdown';
|
||||
import { Clock, ArrowLeft } from 'lucide-react';
|
||||
|
||||
@@ -21,70 +25,106 @@ export async function generateMetadata({ params }) {
|
||||
type: 'article',
|
||||
publishedTime: article.published_at,
|
||||
tags: article.tags || [],
|
||||
images: article.cover_url ? [{ url: article.cover_url }] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRelated(article) {
|
||||
// ищем статьи с пересекающимися тегами, исключая текущую
|
||||
if (!article.tags?.length) return [];
|
||||
const all = await listArticles({ limit: 20 });
|
||||
const scored = all
|
||||
.filter(a => a.id !== article.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
score: (a.tags || []).filter(t => article.tags.includes(t)).length,
|
||||
}))
|
||||
.filter(a => a.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3);
|
||||
return scored;
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }) {
|
||||
const { slug } = await params;
|
||||
const article = await getArticle(slug);
|
||||
if (!article) notFound();
|
||||
|
||||
const related = await loadRelated(article);
|
||||
const contentWithoutH1 = article.content.replace(/^#\s+.+$/m, '').trim();
|
||||
const html = renderMarkdown(contentWithoutH1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<article className="container-narrow pt-10 pb-16">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-6 -ml-2">
|
||||
<ReadingProgress />
|
||||
|
||||
<article className="container-narrow pt-6 sm:pt-10 pb-12">
|
||||
<Link href="/" className="btn btn-ghost text-sm mb-4 sm:mb-6 -ml-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Все статьи
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{(article.tags || []).map(t => (
|
||||
<Link key={t} href={`/tag/${encodeURIComponent(t)}`} className="tag">#{t}</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="font-serif text-3xl sm:text-5xl font-bold leading-tight mb-5 tracking-tight ink">
|
||||
<h1 className="font-serif text-2xl sm:text-4xl lg:text-5xl font-bold leading-[1.15] mb-4 sm:mb-5 tracking-tight ink">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm mute pb-8 mb-8 border-b-soft">
|
||||
<div className="flex items-center flex-wrap gap-x-3 gap-y-2 text-xs sm:text-sm mute pb-6 sm:pb-8 mb-6 sm:mb-8 border-b-soft">
|
||||
<span>{article.author}</span>
|
||||
<span>·</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatDate(article.published_at)}</span>
|
||||
{article.reading_time && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" /> {article.reading_time} мин чтения
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<ShareButton title={article.title} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.cover_url && (
|
||||
<div className="mb-10 -mx-4 sm:mx-0">
|
||||
<div className="mb-8 sm:mb-10 -mx-4 sm:mx-0">
|
||||
<img
|
||||
src={article.cover_url}
|
||||
alt={article.title}
|
||||
className="w-full rounded-xl"
|
||||
className="w-full sm:rounded-xl"
|
||||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="prose prose-lg max-w-none font-serif prose-headings:font-sans"
|
||||
className="prose prose-base sm:prose-lg max-w-none font-serif prose-headings:font-sans prose-p:leading-relaxed prose-img:rounded-xl"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
<div className="mt-16 pt-8 border-t-soft text-center">
|
||||
<div className="mt-12 pt-6 border-t-soft text-center">
|
||||
<p className="mute text-sm">Статья сгенерирована ИИ под редакторским присмотром.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="container-wide pb-12 sm:pb-16">
|
||||
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5">
|
||||
Похожее
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
{related.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ScrollToTop />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
+47
-21
@@ -4,15 +4,15 @@
|
||||
|
||||
/* === Theme tokens === */
|
||||
:root {
|
||||
--bg: 250 250 249; /* почти белый, тёплый */
|
||||
--surface: 255 255 255; /* карточки */
|
||||
--surface-2: 245 245 244; /* приглушённый фон */
|
||||
--bg: 250 250 249;
|
||||
--surface: 255 255 255;
|
||||
--surface-2: 245 245 244;
|
||||
--border: 231 229 228;
|
||||
--ink: 28 25 23; /* основной текст */
|
||||
--mute: 120 113 108; /* приглушённый текст */
|
||||
--accent: 16 185 129; /* emerald-500 */
|
||||
--accent-2: 5 150 105; /* emerald-600 */
|
||||
--accent-soft: 209 250 229; /* emerald-100 */
|
||||
--ink: 28 25 23;
|
||||
--mute: 120 113 108;
|
||||
--accent: 16 185 129;
|
||||
--accent-2: 5 150 105;
|
||||
--accent-soft: 209 250 229;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -31,28 +31,39 @@
|
||||
html {
|
||||
background: rgb(var(--bg));
|
||||
color: rgb(var(--ink));
|
||||
scroll-behavior: smooth;
|
||||
/* лучше для мобильных WebKit */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
body {
|
||||
@apply font-sans antialiased;
|
||||
/* safe areas на iOS */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
body { @apply font-sans antialiased; }
|
||||
::selection { background: rgb(var(--accent) / 0.25); }
|
||||
|
||||
/* tap target минимум 44px для кнопок и ссылок в навигации/действиях */
|
||||
button, a.btn {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* плавный скролл к якорям с учётом sticky header */
|
||||
:where(h1, h2, h3, h4, [id]) {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: rgb(var(--accent));
|
||||
color: white;
|
||||
}
|
||||
.btn-primary { background: rgb(var(--accent)); color: white; }
|
||||
.btn-primary:hover { background: rgb(var(--accent-2)); }
|
||||
|
||||
.btn-ghost {
|
||||
color: rgb(var(--mute));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgb(var(--surface-2));
|
||||
color: rgb(var(--ink));
|
||||
}
|
||||
.btn-ghost { color: rgb(var(--mute)); }
|
||||
.btn-ghost:hover { background: rgb(var(--surface-2)); color: rgb(var(--ink)); }
|
||||
|
||||
.container-narrow { @apply max-w-3xl mx-auto px-4; }
|
||||
.container-wide { @apply max-w-6xl mx-auto px-4; }
|
||||
@@ -70,10 +81,15 @@
|
||||
.dark .article-card:hover {
|
||||
box-shadow: 0 10px 25px -10px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
/* на мобилке hover-эффект transform не нужен (тач) */
|
||||
@media (hover: none) {
|
||||
.article-card:hover { transform: none; box-shadow: none; }
|
||||
.article-card:active { transform: scale(0.99); }
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
@apply text-xs px-2.5 py-1 rounded-full transition-colors;
|
||||
@apply text-xs px-2.5 py-1 rounded-full transition-colors whitespace-nowrap;
|
||||
background: rgb(var(--surface-2));
|
||||
color: rgb(var(--mute));
|
||||
}
|
||||
@@ -91,3 +107,13 @@
|
||||
.border-b-soft { border-bottom: 1px solid rgb(var(--border)); }
|
||||
.border-t-soft { border-top: 1px solid rgb(var(--border)); }
|
||||
}
|
||||
|
||||
/* Уважаем системную настройку "меньше анимаций" */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -12,9 +12,22 @@ export const metadata = {
|
||||
locale: 'ru_RU',
|
||||
siteName: 'ZeroPost',
|
||||
},
|
||||
alternates: {
|
||||
types: { 'application/rss+xml': '/rss.xml' },
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafaf9' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
||||
],
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
// Защита от FOUC: ставим тему до первого рендера
|
||||
const themeInitScript = `
|
||||
(function() {
|
||||
try {
|
||||
@@ -36,6 +49,7 @@ export default function RootLayout({ children }) {
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="alternate" type="application/rss+xml" title="ZeroPost RSS" href="/rss.xml" />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
|
||||
+6
-6
@@ -33,7 +33,7 @@ export default async function HomePage() {
|
||||
{/* Hero */}
|
||||
<section className="relative">
|
||||
<HeroBackground />
|
||||
<div className="container-wide pt-16 pb-12 sm:pt-24 sm:pb-20 relative">
|
||||
<div className="container-wide pt-12 pb-12 sm:pt-24 sm:pb-20 relative">
|
||||
<Reveal>
|
||||
<div className="max-w-3xl reveal">
|
||||
<div
|
||||
@@ -43,18 +43,18 @@ export default async function HomePage() {
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Блог, который ведёт ИИ
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
||||
<h1 className="text-[2.5rem] sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
||||
Практический ИИ.<br />
|
||||
<span className="mute">Без воды и хайпа.</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl mute mb-8 max-w-2xl leading-relaxed">
|
||||
<p className="text-base sm:text-xl mute mb-8 max-w-2xl leading-relaxed">
|
||||
Промпты, кейсы, инструменты и разборы. Эксперимент: блог, который ведёт ИИ — а человек только следит за курсом.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="#articles" className="btn btn-primary">
|
||||
<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>
|
||||
<Link href="/about" className="btn btn-ghost">
|
||||
<Link href="/about" className="btn btn-ghost w-full sm:w-auto">
|
||||
Как это работает
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{ userAgent: '*', allow: '/', disallow: ['/api/'] },
|
||||
],
|
||||
sitemap: 'https://zeropost.ru/sitemap.xml',
|
||||
host: 'https://zeropost.ru',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { listArticles } from '@/lib/engine';
|
||||
|
||||
const SITE = 'https://zeropost.ru';
|
||||
|
||||
function escapeXml(s = '') {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const articles = await listArticles({ limit: 50 });
|
||||
const items = articles.map(a => `
|
||||
<item>
|
||||
<title>${escapeXml(a.title)}</title>
|
||||
<link>${SITE}/blog/${a.slug}</link>
|
||||
<guid isPermaLink="true">${SITE}/blog/${a.slug}</guid>
|
||||
<pubDate>${new Date(a.published_at).toUTCString()}</pubDate>
|
||||
<description>${escapeXml(a.excerpt || '')}</description>
|
||||
${(a.tags || []).map(t => `<category>${escapeXml(t)}</category>`).join('')}
|
||||
</item>`).join('');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>ZeroPost</title>
|
||||
<link>${SITE}</link>
|
||||
<description>Блог про практическое применение ИИ</description>
|
||||
<language>ru</language>
|
||||
<atom:link href="${SITE}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600, s-maxage=600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { listArticles, listTags } from '@/lib/engine';
|
||||
|
||||
const SITE = 'https://zeropost.ru';
|
||||
|
||||
export default async function sitemap() {
|
||||
const [articles, tags] = await Promise.all([
|
||||
listArticles({ limit: 200 }).catch(() => []),
|
||||
listTags().catch(() => []),
|
||||
]);
|
||||
|
||||
const staticPages = [
|
||||
{ url: `${SITE}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
|
||||
{ url: `${SITE}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||
];
|
||||
|
||||
const articlePages = articles.map(a => ({
|
||||
url: `${SITE}/blog/${a.slug}`,
|
||||
lastModified: a.published_at ? new Date(a.published_at) : new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
const tagPages = tags.map(t => ({
|
||||
url: `${SITE}/tag/${encodeURIComponent(t.tag)}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...articlePages, ...tagPages];
|
||||
}
|
||||
+18
-14
@@ -4,10 +4,9 @@ import { Clock } from 'lucide-react';
|
||||
|
||||
function imageUrl(article) {
|
||||
if (!article.cover_url) return null;
|
||||
return article.cover_url; // /uploads/... проксируется через next.config rewrites
|
||||
return article.cover_url;
|
||||
}
|
||||
|
||||
// Детерминированный градиент-фоллбек, чтобы у каждой статьи был свой узнаваемый цвет
|
||||
function gradientFor(article) {
|
||||
const seed = (article.id || 0) * 31 + (article.title?.length || 0);
|
||||
const variants = [
|
||||
@@ -21,14 +20,13 @@ function gradientFor(article) {
|
||||
return variants[seed % variants.length];
|
||||
}
|
||||
|
||||
function CoverPlaceholder({ article, featured = false }) {
|
||||
function CoverPlaceholder({ article, featured = false, className = '' }) {
|
||||
const gradient = gradientFor(article);
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden ${featured ? 'aspect-[16/8]' : 'aspect-[16/9]'} rounded-xl`}
|
||||
className={`relative overflow-hidden ${featured ? 'aspect-[16/9] sm:aspect-[16/10]' : 'aspect-[16/9]'} rounded-xl ${className}`}
|
||||
style={{ background: gradient }}
|
||||
>
|
||||
{/* геометрические декорации */}
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-30" style={{ background: 'rgba(255,255,255,0.3)' }} />
|
||||
<div className="absolute -bottom-12 -left-8 w-32 h-32 rounded-full opacity-20" style={{ background: 'rgba(0,0,0,0.2)' }} />
|
||||
<div className="absolute inset-0 flex items-end p-4">
|
||||
@@ -46,25 +44,31 @@ export default function ArticleCard({ article, featured = false }) {
|
||||
if (featured) {
|
||||
return (
|
||||
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
||||
<div className="grid sm:grid-cols-2 gap-0">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-5 sm:gap-0">
|
||||
{/* Image: full-width на мобиле, 2/5 на десктопе */}
|
||||
<div className="p-3 sm:p-5 sm:col-span-2">
|
||||
{img ? (
|
||||
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-xl" loading="eager" />
|
||||
<img
|
||||
src={img}
|
||||
alt={article.title}
|
||||
className="w-full aspect-[16/9] sm:aspect-square object-cover rounded-xl"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<CoverPlaceholder article={article} featured />
|
||||
<CoverPlaceholder article={article} className="sm:aspect-square" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 sm:p-8 flex flex-col justify-center">
|
||||
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{(article.tags || []).slice(0, 3).map(t => (
|
||||
<span key={t} className="tag">#{t}</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
|
||||
<h2 className="text-xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="mute leading-relaxed line-clamp-3 mb-4">
|
||||
<p className="mute text-sm sm:text-base leading-relaxed line-clamp-3 mb-4">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
@@ -91,13 +95,13 @@ export default function ArticleCard({ article, featured = false }) {
|
||||
<CoverPlaceholder article={article} />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 pt-2">
|
||||
<div className="p-4 sm:p-5 pt-2">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
{(article.tags || []).slice(0, 2).map(t => (
|
||||
<span key={t} className="tag">#{t}</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.excerpt && (
|
||||
|
||||
+94
-14
@@ -1,25 +1,105 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Sparkles, Menu, X } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import SearchBox from './SearchBox';
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let lastY = window.scrollY;
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
setScrolled(y > 12);
|
||||
if (y < 80) { setHidden(false); lastY = y; return; }
|
||||
const goingDown = y > lastY;
|
||||
if (Math.abs(y - lastY) > 8) {
|
||||
setHidden(goingDown);
|
||||
lastY = y;
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b-soft" style={{ background: 'rgb(var(--bg) / 0.85)', backdropFilter: 'saturate(180%) blur(12px)' }}>
|
||||
<div className="container-wide flex items-center justify-between py-4">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" style={{ background: 'rgb(var(--accent) / 0.12)' }}>
|
||||
<Sparkles className="w-4 h-4 accent" />
|
||||
</div>
|
||||
<span className="font-bold text-lg tracking-tight ink">ZeroPost</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 sm:gap-2 text-sm">
|
||||
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
|
||||
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
|
||||
<div className="ml-1">
|
||||
<>
|
||||
<header
|
||||
className={`sticky top-0 z-30 border-b-soft transition-transform duration-300 ${hidden ? '-translate-y-full' : 'translate-y-0'}`}
|
||||
style={{
|
||||
background: scrolled ? 'rgb(var(--bg) / 0.85)' : 'rgb(var(--bg) / 0.6)',
|
||||
backdropFilter: 'saturate(180%) blur(12px)',
|
||||
WebkitBackdropFilter: 'saturate(180%) blur(12px)',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
>
|
||||
<div className="container-wide flex items-center justify-between py-3 sm:py-4">
|
||||
<Link href="/" className="flex items-center gap-2 group min-w-0" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors"
|
||||
style={{ background: 'rgb(var(--accent) / 0.12)' }}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 accent" />
|
||||
</div>
|
||||
<span className="font-bold text-lg tracking-tight ink truncate">ZeroPost</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden sm:flex items-center gap-1 text-sm">
|
||||
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
|
||||
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
<SearchBox />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="sm:hidden flex items-center gap-1">
|
||||
<SearchBox />
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-10 h-10 inline-flex items-center justify-center rounded-lg mute hover:ink transition-colors"
|
||||
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
className={`sm:hidden fixed inset-0 z-20 transition-opacity duration-300 ${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
||||
style={{ background: 'rgb(var(--bg) / 0.98)', paddingTop: 'calc(64px + env(safe-area-inset-top))' }}
|
||||
>
|
||||
<nav className="container-wide pt-6 pb-8 flex flex-col gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||
>Статьи</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
||||
>О проекте</Link>
|
||||
<div className="mt-6 mute text-sm">
|
||||
Блог, который ведёт ИИ — а человек только следит за курсом.
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ReadingProgress() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const calc = () => {
|
||||
const doc = document.documentElement;
|
||||
const total = doc.scrollHeight - doc.clientHeight;
|
||||
const scrolled = window.scrollY;
|
||||
setProgress(total > 0 ? Math.min(100, (scrolled / total) * 100) : 0);
|
||||
};
|
||||
calc();
|
||||
window.addEventListener('scroll', calc, { passive: true });
|
||||
window.addEventListener('resize', calc);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', calc);
|
||||
window.removeEventListener('resize', calc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-40 pointer-events-none"
|
||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<div
|
||||
className="h-[3px] origin-left transition-transform duration-100"
|
||||
style={{
|
||||
background: 'rgb(var(--accent))',
|
||||
transform: `scaleX(${progress / 100})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > 800);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
function scrollUp() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollUp}
|
||||
aria-label="Наверх"
|
||||
className={`fixed z-30 right-4 sm:right-6 bottom-4 sm:bottom-6 w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-all ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'}`}
|
||||
style={{
|
||||
background: 'rgb(var(--accent))',
|
||||
color: 'white',
|
||||
marginBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, X, Loader2, FileText } from 'lucide-react';
|
||||
|
||||
export default function SearchBox() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
// открытие хоткеем /
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === '/' && !['INPUT','TEXTAREA'].includes(document.activeElement?.tagName)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
setQ('');
|
||||
setResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// дебаунсовый поиск
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (q.trim().length < 2) { setResults([]); setLoading(false); return; }
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}`);
|
||||
const data = await r.json();
|
||||
setResults(Array.isArray(data) ? data : []);
|
||||
} catch { setResults([]); }
|
||||
setLoading(false);
|
||||
}, 250);
|
||||
}, [q, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-10 h-10 inline-flex items-center justify-center rounded-lg mute hover:ink hover:surface-2 transition-colors"
|
||||
aria-label="Поиск"
|
||||
title="Поиск (нажми /)"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-start justify-center p-4 sm:pt-[10vh]"
|
||||
style={{ background: 'rgb(0 0 0 / 0.5)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-2xl overflow-hidden flex flex-col"
|
||||
style={{ background: 'rgb(var(--surface))', border: '1px solid rgb(var(--border))', maxHeight: '85vh' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 sm:px-5 py-3 border-b-soft">
|
||||
<Search className="w-5 h-5 mute flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="Найти статью…"
|
||||
className="flex-1 bg-transparent outline-none text-base sm:text-lg ink placeholder:text-stone-400"
|
||||
/>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin mute" />}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-md mute hover:ink hover:surface-2"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{q.trim().length < 2 && (
|
||||
<div className="px-5 py-8 text-sm mute text-center">
|
||||
Введи минимум 2 символа. <span className="hidden sm:inline">Подсказка: <kbd className="px-1.5 py-0.5 rounded surface-2 text-xs">/</kbd> открывает поиск.</span>
|
||||
</div>
|
||||
)}
|
||||
{q.trim().length >= 2 && !loading && results.length === 0 && (
|
||||
<div className="px-5 py-8 text-sm mute text-center">Ничего не найдено</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<ul className="py-2">
|
||||
{results.map(a => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/blog/${a.slug}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-start gap-3 px-5 py-3 hover:surface-2 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4 mute flex-shrink-0 mt-1" />
|
||||
<div className="min-w-0">
|
||||
<div className="ink font-medium line-clamp-1">{a.title}</div>
|
||||
{a.excerpt && <div className="mute text-sm line-clamp-2 mt-0.5">{a.excerpt}</div>}
|
||||
{a.tags?.length > 0 && (
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
{a.tags.slice(0, 3).map(t => <span key={t} className="tag text-[10px]">#{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Share2, Check, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
export default function ShareButton({ title, url }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function share() {
|
||||
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url: fullUrl });
|
||||
} catch {
|
||||
// отмена пользователем — игнор
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={share}
|
||||
className="btn btn-ghost text-sm py-1.5"
|
||||
aria-label="Поделиться"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="w-4 h-4" /> Скопировано</>
|
||||
) : (
|
||||
<><Share2 className="w-4 h-4" /> Поделиться</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+9
-9
@@ -2,16 +2,16 @@ import { BookOpen, Clock, Brain, Eye } from 'lucide-react';
|
||||
|
||||
function StatCard({ icon: Icon, value, label }) {
|
||||
return (
|
||||
<div className="article-card flex items-center gap-4 p-5">
|
||||
<div className="article-card flex items-center gap-3 sm:gap-4 p-4 sm:p-5">
|
||||
<div
|
||||
className="w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
className="w-10 h-10 sm:w-11 sm:h-11 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'rgb(var(--accent) / 0.1)' }}
|
||||
>
|
||||
<Icon className="w-5 h-5 accent" />
|
||||
<Icon className="w-4 h-4 sm:w-5 sm:h-5 accent" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-2xl font-bold ink leading-none mb-1 tabular-nums">{value}</div>
|
||||
<div className="text-xs mute uppercase tracking-wider">{label}</div>
|
||||
<div className="text-xl sm:text-2xl font-bold ink leading-none mb-1 tabular-nums">{value}</div>
|
||||
<div className="text-[10px] sm:text-xs mute uppercase tracking-wider truncate">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -28,13 +28,13 @@ export default function Stats({ data }) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<section className="container-wide pb-12">
|
||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-5">
|
||||
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute mb-4 sm:mb-5">
|
||||
Что уже написал ИИ
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<StatCard icon={BookOpen} value={fmt(data.articles_count)} label="статей" />
|
||||
<StatCard icon={Clock} value={fmt(data.total_reading_min)} label="минут чтения" />
|
||||
<StatCard icon={Brain} value={fmt(data.tokens_out)} label="токенов сгенерировано" />
|
||||
<StatCard icon={Clock} value={fmt(data.total_reading_min)} label="мин чтения" />
|
||||
<StatCard icon={Brain} value={fmt(data.tokens_out)} label="токенов" />
|
||||
<StatCard icon={Eye} value={fmt(data.total_views)} label="просмотров" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user