diff --git a/app/admin/api/articles/[id]/cover/route.js b/app/admin/api/articles/[id]/cover/route.js
new file mode 100644
index 0000000..bed4cf8
--- /dev/null
+++ b/app/admin/api/articles/[id]/cover/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { checkAdminAuth } from '@/lib/adminAuth';
+import { adminBackfillCovers } from '@/lib/engine';
+
+export async function POST(req, { params }) {
+ if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const { id } = await params;
+ // Сбрасываем cover_url и запускаем backfill для этой статьи
+ const { adminUpdateArticle } = await import('@/lib/engine');
+ await adminUpdateArticle(id, { cover_url: null });
+ // backfill подхватит статью без обложки
+ const result = await adminBackfillCovers(1);
+ const article = result.results?.[0];
+ if (article?.status === 'ok') {
+ return NextResponse.json({ cover_url: article.url });
+ }
+ return NextResponse.json({ error: 'Cover generation failed' }, { status: 500 });
+}
diff --git a/app/admin/api/articles/[id]/route.js b/app/admin/api/articles/[id]/route.js
new file mode 100644
index 0000000..17119a0
--- /dev/null
+++ b/app/admin/api/articles/[id]/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { checkAdminAuth } from '@/lib/adminAuth';
+import { adminUpdateArticle, adminDeleteArticle } from '@/lib/engine';
+
+export async function PATCH(req, { params }) {
+ if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const { id } = await params;
+ const body = await req.json();
+ const result = await adminUpdateArticle(id, body);
+ return NextResponse.json(result);
+}
+
+export async function DELETE(req, { params }) {
+ if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const { id } = await params;
+ const result = await adminDeleteArticle(id);
+ return NextResponse.json(result);
+}
diff --git a/app/admin/api/articles/generate/route.js b/app/admin/api/articles/generate/route.js
new file mode 100644
index 0000000..17861eb
--- /dev/null
+++ b/app/admin/api/articles/generate/route.js
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { checkAdminAuth } from '@/lib/adminAuth';
+
+const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3040';
+const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
+
+export async function POST(req) {
+ if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const body = await req.json();
+ const res = await fetch(`${ENGINE_URL}/api/articles/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) return NextResponse.json({ error: await res.text() }, { status: res.status });
+ return NextResponse.json(await res.json());
+}
diff --git a/app/admin/api/login/route.js b/app/admin/api/login/route.js
new file mode 100644
index 0000000..8c54fca
--- /dev/null
+++ b/app/admin/api/login/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { SESSION_COOKIE, VALID_TOKEN } from '@/lib/adminAuth';
+
+export async function POST(req) {
+ const { password } = await req.json();
+ if (password !== VALID_TOKEN) {
+ return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
+ }
+ const res = NextResponse.json({ ok: true });
+ res.cookies.set(SESSION_COOKIE, VALID_TOKEN, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24 * 30, // 30 дней
+ path: '/',
+ });
+ return res;
+}
diff --git a/app/admin/api/logout/route.js b/app/admin/api/logout/route.js
new file mode 100644
index 0000000..4ae30da
--- /dev/null
+++ b/app/admin/api/logout/route.js
@@ -0,0 +1,8 @@
+import { NextResponse } from 'next/server';
+import { SESSION_COOKIE } from '@/lib/adminAuth';
+
+export async function POST() {
+ const res = NextResponse.json({ ok: true });
+ res.cookies.set(SESSION_COOKIE, '', { maxAge: 0, path: '/' });
+ return res;
+}
diff --git a/app/admin/articles/[id]/page.js b/app/admin/articles/[id]/page.js
new file mode 100644
index 0000000..74ba271
--- /dev/null
+++ b/app/admin/articles/[id]/page.js
@@ -0,0 +1,29 @@
+import { requireAdminAuth } from '@/lib/adminAuth';
+import { adminGetArticle } from '@/lib/engine';
+import ArticleEditor from '@/components/admin/ArticleEditor';
+import { notFound } from 'next/navigation';
+
+export const dynamic = 'force-dynamic';
+
+export async function generateMetadata({ params }) {
+ const { id } = await params;
+ if (id === 'new') return { title: 'Новая статья' };
+ try {
+ const a = await adminGetArticle(id);
+ return { title: a?.title ? `Редактировать: ${a.title.slice(0, 40)}` : 'Редактировать' };
+ } catch { return { title: 'Редактировать' }; }
+}
+
+export default async function AdminArticlePage({ params }) {
+ await requireAdminAuth();
+ const { id } = await params;
+
+ let article = null;
+ if (id !== 'new') {
+ try { article = await adminGetArticle(id); }
+ catch { notFound(); }
+ if (!article) notFound();
+ }
+
+ return ;
+}
diff --git a/app/admin/articles/new/page.js b/app/admin/articles/new/page.js
new file mode 100644
index 0000000..cf85937
--- /dev/null
+++ b/app/admin/articles/new/page.js
@@ -0,0 +1,9 @@
+import ArticleEditor from '@/components/admin/ArticleEditor';
+import { requireAdminAuth } from '@/lib/adminAuth';
+
+export const metadata = { title: 'Новая статья' };
+
+export default async function NewArticlePage() {
+ await requireAdminAuth();
+ return ;
+}
diff --git a/app/admin/articles/page.js b/app/admin/articles/page.js
new file mode 100644
index 0000000..162f906
--- /dev/null
+++ b/app/admin/articles/page.js
@@ -0,0 +1,100 @@
+import Link from 'next/link';
+import { requireAdminAuth } from '@/lib/adminAuth';
+import { adminListArticles } from '@/lib/engine';
+import { Plus, Pencil, Eye } from 'lucide-react';
+
+export const dynamic = 'force-dynamic';
+export const metadata = { title: 'Статьи' };
+
+export default async function AdminArticlesPage() {
+ await requireAdminAuth();
+ const raw = await adminListArticles({ limit: 100 });
+ const articles = Array.isArray(raw) ? raw : raw?.articles || [];
+
+ return (
+
+
+
Статьи
+
+
+ Новая статья
+
+
+
+
+
+
+
+ | Статья |
+ Теги |
+ Статус |
+ Дата |
+ Действия |
+
+
+
+ {articles.map(a => (
+
+
+
+
+ {a.cover_url &&  }
+
+
+
+ |
+
+
+ {(a.tags || []).slice(0, 3).map(t => (
+ #{t}
+ ))}
+
+ |
+
+
+ {a.status === 'published' ? 'Опубликована' : 'Черновик'}
+
+ |
+
+ {a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU') : '—'}
+ |
+
+
+ |
+
+ ))}
+
+
+ {articles.length === 0 && (
+
Статей пока нет
+ )}
+
+
+ );
+}
diff --git a/app/admin/layout.js b/app/admin/layout.js
new file mode 100644
index 0000000..cc11ada
--- /dev/null
+++ b/app/admin/layout.js
@@ -0,0 +1,16 @@
+import { requireAdminAuth } from '@/lib/adminAuth';
+import AdminNav from '@/components/admin/AdminNav';
+
+export const metadata = { title: { default: 'Admin — ZeroPost', template: '%s — Admin' } };
+
+export default async function AdminLayout({ children }) {
+ await requireAdminAuth();
+ return (
+
+ );
+}
diff --git a/app/admin/login/page.js b/app/admin/login/page.js
new file mode 100644
index 0000000..307e3a9
--- /dev/null
+++ b/app/admin/login/page.js
@@ -0,0 +1,61 @@
+'use client';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+export default function AdminLoginPage() {
+ const router = useRouter();
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setLoading(true);
+ setError('');
+ const res = await fetch('/admin/api/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ });
+ if (res.ok) {
+ router.push('/admin');
+ router.refresh();
+ } else {
+ setError('Неверный пароль');
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
Z
+
ZeroPost Admin
+
Введите пароль для входа
+
+
+
+
+ );
+}
diff --git a/app/admin/page.js b/app/admin/page.js
new file mode 100644
index 0000000..9043e40
--- /dev/null
+++ b/app/admin/page.js
@@ -0,0 +1,158 @@
+import Link from 'next/link';
+import { requireAdminAuth } from '@/lib/adminAuth';
+import { adminListArticles, getStats } from '@/lib/engine';
+import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw } from 'lucide-react';
+
+export const dynamic = 'force-dynamic';
+export const metadata = { title: 'Дашборд' };
+
+function StatCard({ label, value, icon: Icon, color = 'emerald' }) {
+ const colors = {
+ emerald: 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400',
+ blue: 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400',
+ amber: 'bg-amber-50 dark:bg-amber-950 text-amber-600 dark:text-amber-400',
+ };
+ return (
+
+ );
+}
+
+export default async function AdminDashboard() {
+ await requireAdminAuth();
+
+ const [articles, stats] = await Promise.allSettled([
+ adminListArticles({ limit: 50 }),
+ getStats(),
+ ]);
+
+ const arts = articles.status === 'fulfilled' ? (Array.isArray(articles.value) ? articles.value : articles.value?.articles || []) : [];
+ const st = stats.status === 'fulfilled' ? stats.value : null;
+
+ const published = arts.filter(a => a.status === 'published').length;
+ const drafts = arts.filter(a => a.status === 'draft').length;
+ const withoutCover = arts.filter(a => !a.cover_url).length;
+ const recentArts = arts.slice(0, 8);
+
+ return (
+
+
+
+
Дашборд
+
Управление контентом zeropost.ru
+
+
+
+ Новая статья
+
+
+
+ {/* Статистика */}
+
+
+
+
+ 0 ? 'amber' : 'emerald'} />
+
+
+ {/* Быстрые действия */}
+
+
Быстрые действия
+
+
+
+ Написать статью
+
+
+
+ Все статьи
+
+ {withoutCover > 0 && (
+
+ )}
+
+
+
+ {/* Последние статьи */}
+
+
+
Последние статьи
+
+ Все →
+
+
+
+ {recentArts.map(a => (
+
+ {/* Обложка-превью */}
+
+ {a.cover_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {a.title}
+
+
+
+ {a.status === 'published' ? 'Опубликовано' : 'Черновик'}
+
+ {a.tags?.slice(0, 2).map(t => (
+ #{t}
+ ))}
+
+
+
+ {a.published_at ? new Date(a.published_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) : '—'}
+
+
+ ))}
+
+
+
+ );
+}
+
+// Кнопка backfill — клиентская
+function BackfillButton({ count }) {
+ return (
+
+ );
+}
diff --git a/components/admin/AdminNav.js b/components/admin/AdminNav.js
new file mode 100644
index 0000000..e76e1d4
--- /dev/null
+++ b/components/admin/AdminNav.js
@@ -0,0 +1,71 @@
+'use client';
+import Link from 'next/link';
+import { usePathname, useRouter } from 'next/navigation';
+import { LayoutDashboard, FileText, LogOut, ExternalLink } from 'lucide-react';
+
+const NAV = [
+ { href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
+ { href: '/admin/articles', label: 'Статьи', icon: FileText },
+];
+
+export default function AdminNav() {
+ const pathname = usePathname();
+ const router = useRouter();
+
+ async function logout() {
+ await fetch('/admin/api/logout', { method: 'POST' });
+ router.push('/admin/login');
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
diff --git a/components/admin/ArticleEditor.js b/components/admin/ArticleEditor.js
new file mode 100644
index 0000000..a2daf11
--- /dev/null
+++ b/components/admin/ArticleEditor.js
@@ -0,0 +1,349 @@
+'use client';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Trash2, RefreshCw, Eye, Sparkles } from 'lucide-react';
+
+export default function ArticleEditor({ article }) {
+ const router = useRouter();
+ const isNew = !article;
+
+ const [title, setTitle] = useState(article?.title || '');
+ const [excerpt, setExcerpt] = useState(article?.excerpt || '');
+ const [content, setContent] = useState(article?.content || '');
+ const [tags, setTags] = useState((article?.tags || []).join(', '));
+ const [status, setStatus] = useState(article?.status || 'draft');
+ const [seoTitle, setSeoTitle] = useState(article?.seo_title || '');
+ const [seoDescr, setSeoDescr] = useState(article?.seo_descr || '');
+ const [coverUrl, setCoverUrl] = useState(article?.cover_url || '');
+
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [regenerating, setRegenerating] = useState(false);
+ const [generating, setGenerating] = useState(false);
+ const [toast, setToast] = useState(null);
+ const [genTopic, setGenTopic] = useState('');
+ const [showGenModal, setShowGenModal] = useState(false);
+
+ function showToast(msg, type = 'success') {
+ setToast({ msg, type });
+ setTimeout(() => setToast(null), 3000);
+ }
+
+ async function save() {
+ if (!title.trim()) return showToast('Укажите заголовок', 'error');
+ setSaving(true);
+ try {
+ const body = {
+ title: title.trim(),
+ excerpt: excerpt.trim(),
+ content: content.trim(),
+ tags: tags.split(',').map(t => t.trim()).filter(Boolean),
+ status,
+ seo_title: seoTitle.trim() || null,
+ seo_descr: seoDescr.trim() || null,
+ cover_url: coverUrl.trim() || null,
+ };
+ if (isNew) {
+ // Генерируем через engine
+ const res = await fetch('/admin/api/articles', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const created = await res.json();
+ showToast('Статья создана');
+ router.push(`/admin/articles/${created.id}`);
+ } else {
+ const res = await fetch(`/admin/api/articles/${article.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ showToast('Сохранено');
+ router.refresh();
+ }
+ } catch (e) {
+ showToast(e.message.slice(0, 100), 'error');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function deleteArticle() {
+ if (!confirm('Удалить статью? Это действие необратимо.')) return;
+ setDeleting(true);
+ try {
+ const res = await fetch(`/admin/api/articles/${article.id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error(await res.text());
+ router.push('/admin/articles');
+ } catch (e) {
+ showToast(e.message.slice(0, 100), 'error');
+ setDeleting(false);
+ }
+ }
+
+ async function regenerateCover() {
+ if (!article?.id) return;
+ setRegenerating(true);
+ try {
+ const res = await fetch(`/admin/api/articles/${article.id}/cover`, { method: 'POST' });
+ if (!res.ok) throw new Error(await res.text());
+ const { cover_url } = await res.json();
+ setCoverUrl(cover_url);
+ showToast('Обложка обновлена');
+ router.refresh();
+ } catch (e) {
+ showToast('Ошибка: ' + e.message.slice(0, 80), 'error');
+ } finally {
+ setRegenerating(false);
+ }
+ }
+
+ async function generateWithAI() {
+ if (!genTopic.trim()) return;
+ setGenerating(true);
+ setShowGenModal(false);
+ try {
+ const res = await fetch('/admin/api/articles/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ topic: genTopic, autoPublish: false }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const a = await res.json();
+ router.push(`/admin/articles/${a.id}`);
+ } catch (e) {
+ showToast('Ошибка генерации: ' + e.message.slice(0, 80), 'error');
+ setGenerating(false);
+ }
+ }
+
+ const ENGINE_URL = process.env.NEXT_PUBLIC_ENGINE_URL || '';
+
+ return (
+
+ {/* Toast */}
+ {toast && (
+
+ {toast.msg}
+
+ )}
+
+ {/* Заголовок страницы */}
+
+
+
+
+
+
+ {isNew ? 'Новая статья' : 'Редактировать статью'}
+
+
+
+ {isNew && (
+
+ )}
+ {!isNew && article.slug && (
+
+
+ Просмотр
+
+ )}
+ {!isNew && (
+
+ )}
+
+
+
+
+ {/* AI-генерация modal */}
+ {showGenModal && (
+
setShowGenModal(false)}>
+
e.stopPropagation()}>
+
Сгенерировать статью AI
+
setGenTopic(e.target.value)}
+ placeholder="Тема статьи, например: промпт-инжиниринг для e-commerce"
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 mb-4"
+ autoFocus
+ onKeyDown={e => e.key === 'Enter' && generateWithAI()}
+ />
+
+
+
+
+
+
+ )}
+
+
+ {/* Основное */}
+
+
+
+
+ setTitle(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ placeholder="Заголовок статьи"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Боковая панель */}
+
+ {/* Статус */}
+
+
Публикация
+
+
+
+ setTags(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ placeholder="ai, prompts, tools"
+ />
+
+
+
+ {/* Обложка */}
+
+
Обложка
+ {coverUrl && (
+

+ )}
+
setCoverUrl(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ placeholder="/uploads/cover-xxx.webp"
+ />
+ {!isNew && (
+
+ )}
+
+
+ {/* SEO */}
+
+
SEO
+
+
+ setSeoTitle(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ placeholder={title || 'SEO заголовок'}
+ />
+
+
+
+
+ {/* Инфо */}
+ {!isNew && (
+
+
ID{article.id}
+
Slug{article.slug}
+ {article.reading_time &&
Чтение{article.reading_time} мин
}
+ {article.published_at &&
Опубликована{new Date(article.published_at).toLocaleDateString('ru-RU')}
}
+
+ )}
+
+
+
+ );
+}
diff --git a/lib/adminAuth.js b/lib/adminAuth.js
new file mode 100644
index 0000000..658a2af
--- /dev/null
+++ b/lib/adminAuth.js
@@ -0,0 +1,20 @@
+import { cookies } from 'next/headers';
+
+const SESSION_COOKIE = 'zp_admin_session';
+const VALID_TOKEN = process.env.ADMIN_PASSWORD || 'zeropost_admin_2026';
+
+export async function checkAdminAuth() {
+ const jar = await cookies();
+ const token = jar.get(SESSION_COOKIE)?.value;
+ return token === VALID_TOKEN;
+}
+
+export async function requireAdminAuth() {
+ const ok = await checkAdminAuth();
+ if (!ok) {
+ const { redirect } = await import('next/navigation');
+ redirect('/admin/login');
+ }
+}
+
+export { SESSION_COOKIE, VALID_TOKEN };
diff --git a/lib/engine.js b/lib/engine.js
index 2e27870..b037bf3 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -71,3 +71,39 @@ export async function generateArticle(data) {
body: JSON.stringify(data),
});
}
+
+// ── Admin API ─────────────────────────────────────────────────────────────────
+
+export async function adminListArticles({ limit = 50, offset = 0 } = {}) {
+ return call(`/api/articles?limit=${limit}&offset=${offset}`);
+}
+
+export async function adminGetArticle(id) {
+ return call(`/api/articles/id/${id}`);
+}
+
+export async function adminUpdateArticle(id, data) {
+ return call(`/api/articles/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ });
+}
+
+export async function adminDeleteArticle(id) {
+ return call(`/api/articles/${id}`, { method: 'DELETE' });
+}
+
+export async function adminBackfillCovers(limit = 5) {
+ return call('/api/articles/backfill-covers', {
+ method: 'POST',
+ body: JSON.stringify({ limit }),
+ });
+}
+
+export async function adminGenerateArticle(topic, tags = []) {
+ return call('/api/articles/generate', {
+ method: 'POST',
+ body: JSON.stringify({ topic, tags, autoPublish: false }),
+ });
+}
+