+
{formatDate(article.published_at)}
{article.reading_time && (
@@ -31,30 +111,6 @@ export default function ArticleCard({ article, featured = false }) {
)}
-
- );
- }
-
- return (
-
-
- {(article.tags || []).slice(0, 2).map(t => (
- #{t}
- ))}
-
-
- {article.title}
-
- {article.excerpt && (
-
{article.excerpt}
- )}
-
- {formatDate(article.published_at)}
- {article.reading_time && (
-
- {article.reading_time} мин
-
- )}
);
diff --git a/components/HeroBackground.js b/components/HeroBackground.js
new file mode 100644
index 0000000..ab6bd3c
--- /dev/null
+++ b/components/HeroBackground.js
@@ -0,0 +1,70 @@
+'use client';
+
+export default function HeroBackground() {
+ return (
+
+ {/* Mesh gradient — несколько размытых blob'ов с медленной анимацией */}
+
+
+
+
+ {/* Сетка точек поверх — тонкий фоновый паттерн */}
+
+
+ {/* Виньетка к низу — плавный переход к контенту */}
+
+
+
+
+ );
+}
diff --git a/components/Reveal.js b/components/Reveal.js
new file mode 100644
index 0000000..d1ef78e
--- /dev/null
+++ b/components/Reveal.js
@@ -0,0 +1,50 @@
+'use client';
+import { useEffect, useRef } from 'react';
+
+/**
+ * Lightweight reveal-on-scroll. Без зависимостей, нативный IntersectionObserver.
+ * Дочерние элементы с классом `.reveal` плавно появятся.
+ */
+export default function Reveal({ children, className = '' }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!ref.current) return;
+ const io = new IntersectionObserver(
+ entries => {
+ entries.forEach(e => {
+ if (e.isIntersecting) {
+ e.target.classList.add('is-visible');
+ io.unobserve(e.target);
+ }
+ });
+ },
+ { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
+ );
+ ref.current.querySelectorAll('.reveal').forEach(el => io.observe(el));
+ return () => io.disconnect();
+ }, []);
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/components/Stats.js b/components/Stats.js
new file mode 100644
index 0000000..9b884f6
--- /dev/null
+++ b/components/Stats.js
@@ -0,0 +1,42 @@
+import { BookOpen, Clock, Brain, Eye } from 'lucide-react';
+
+function StatCard({ icon: Icon, value, label }) {
+ return (
+
+ );
+}
+
+function fmt(n) {
+ if (!n) return '0';
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
+ return n.toLocaleString('ru-RU');
+}
+
+export default function Stats({ data }) {
+ if (!data) return null;
+ return (
+
+
+ Что уже написал ИИ
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/engine.js b/lib/engine.js
index 46d368d..a05c868 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -37,6 +37,14 @@ export async function listTags() {
return call('/api/articles/tags', { next: { revalidate: 300 } });
}
+export async function getStats() {
+ try {
+ return await call('/api/stats', { cache: 'no-store' });
+ } catch {
+ return null;
+ }
+}
+
export async function generateArticle(data) {
return call('/api/articles/generate', {
method: 'POST',
diff --git a/next.config.js b/next.config.js
index 3dd7ef1..3621358 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,4 +1,12 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
+ async rewrites() {
+ return [
+ {
+ source: '/uploads/:path*',
+ destination: `${process.env.ENGINE_URL || 'http://127.0.0.1:3040'}/uploads/:path*`,
+ },
+ ];
+ },
};