From 5fc85a31d44d60a73000be1e7f7be5157ccd1c7f Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 14:17:58 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20panel=20=E2=80=94=20dashboard,?= =?UTF-8?q?=20articles=20list,=20editor,=20auth,=20cover=20regen,=20AI=20g?= =?UTF-8?q?enerate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/api/articles/[id]/cover/route.js | 18 ++ app/admin/api/articles/[id]/route.js | 18 ++ app/admin/api/articles/generate/route.js | 17 + app/admin/api/login/route.js | 18 ++ app/admin/api/logout/route.js | 8 + app/admin/articles/[id]/page.js | 29 ++ app/admin/articles/new/page.js | 9 + app/admin/articles/page.js | 100 ++++++ app/admin/layout.js | 16 + app/admin/login/page.js | 61 ++++ app/admin/page.js | 158 ++++++++++ components/admin/AdminNav.js | 71 +++++ components/admin/ArticleEditor.js | 349 +++++++++++++++++++++ lib/adminAuth.js | 20 ++ lib/engine.js | 36 +++ 15 files changed, 928 insertions(+) create mode 100644 app/admin/api/articles/[id]/cover/route.js create mode 100644 app/admin/api/articles/[id]/route.js create mode 100644 app/admin/api/articles/generate/route.js create mode 100644 app/admin/api/login/route.js create mode 100644 app/admin/api/logout/route.js create mode 100644 app/admin/articles/[id]/page.js create mode 100644 app/admin/articles/new/page.js create mode 100644 app/admin/articles/page.js create mode 100644 app/admin/layout.js create mode 100644 app/admin/login/page.js create mode 100644 app/admin/page.js create mode 100644 components/admin/AdminNav.js create mode 100644 components/admin/ArticleEditor.js create mode 100644 lib/adminAuth.js 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.title}
+
{a.slug}
+
+
+
+
+ {(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 ( +
+ +
+ {children} +
+
+ ); +} 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

+

Введите пароль для входа

+
+
+
+ + setPassword(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="••••••••" + autoFocus + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} 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 ( +
+
+
+ +
+ {label} +
+
{value ?? '—'}
+
+ ); +} + +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 ( +
+ + + Догенерировать обложки ({count}) + + + ); +} 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 ( +
+
+ {/* Логотип */} + + Z + Admin + + + {/* Навигация */} + + + {/* Правая часть */} +
+ + + Сайт + + +
+
+
+ ); +} 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="Заголовок статьи" + /> +
+
+ +