From 80325b44352fa05212f5415321e0e0274290c306 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 14:37:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20channels=20=E2=80=94=20list,=20?= =?UTF-8?q?editor,=20publish=20panel,=20TG/VK/Max=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/(protected)/channels/[id]/page.js | 33 ++ app/admin/(protected)/channels/new/page.js | 9 + app/admin/(protected)/channels/page.js | 89 +++++ app/admin/api/channels/[id]/publish/route.js | 15 + app/admin/api/channels/[id]/route.js | 18 + app/admin/api/channels/route.js | 16 + components/admin/AdminNav.js | 3 +- components/admin/ChannelEditor.js | 326 +++++++++++++++++++ lib/engine.js | 36 ++ 9 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 app/admin/(protected)/channels/[id]/page.js create mode 100644 app/admin/(protected)/channels/new/page.js create mode 100644 app/admin/(protected)/channels/page.js create mode 100644 app/admin/api/channels/[id]/publish/route.js create mode 100644 app/admin/api/channels/[id]/route.js create mode 100644 app/admin/api/channels/route.js create mode 100644 components/admin/ChannelEditor.js diff --git a/app/admin/(protected)/channels/[id]/page.js b/app/admin/(protected)/channels/[id]/page.js new file mode 100644 index 0000000..cc6e7b8 --- /dev/null +++ b/app/admin/(protected)/channels/[id]/page.js @@ -0,0 +1,33 @@ +import { requireAdminAuth } from '@/lib/adminAuth'; +import { adminListChannels, adminListArticles } from '@/lib/engine'; +import ChannelEditor from '@/components/admin/ChannelEditor'; +import { notFound } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export async function generateMetadata({ params }) { + const { id } = await params; + if (id === 'new') return { title: 'Новый канал' }; + return { title: 'Настройки канала' }; +} + +export default async function AdminChannelPage({ params }) { + await requireAdminAuth(); + const { id } = await params; + + let channel = null; + let articles = []; + + if (id !== 'new') { + const channels = await adminListChannels(); + channel = channels.find(c => String(c.id) === id); + if (!channel) notFound(); + } + + try { + const raw = await adminListArticles({ limit: 50 }); + articles = (Array.isArray(raw) ? raw : raw?.articles || []).filter(a => a.status === 'published'); + } catch {} + + return ; +} diff --git a/app/admin/(protected)/channels/new/page.js b/app/admin/(protected)/channels/new/page.js new file mode 100644 index 0000000..b0370ff --- /dev/null +++ b/app/admin/(protected)/channels/new/page.js @@ -0,0 +1,9 @@ +import { requireAdminAuth } from '@/lib/adminAuth'; +import ChannelEditor from '@/components/admin/ChannelEditor'; + +export const metadata = { title: 'Новый канал' }; + +export default async function NewChannelPage() { + await requireAdminAuth(); + return ; +} diff --git a/app/admin/(protected)/channels/page.js b/app/admin/(protected)/channels/page.js new file mode 100644 index 0000000..92ecc62 --- /dev/null +++ b/app/admin/(protected)/channels/page.js @@ -0,0 +1,89 @@ +import { requireAdminAuth } from '@/lib/adminAuth'; +import { adminListChannels } from '@/lib/engine'; +import Link from 'next/link'; +import { Plus, Send, MessageSquare, Users } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; +export const metadata = { title: 'Каналы' }; + +const PLATFORM_LABELS = { + telegram: { label: 'Telegram', color: 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400', icon: '✈️' }, + vk: { label: 'ВКонтакте', color: 'bg-indigo-50 dark:bg-indigo-950 text-indigo-600 dark:text-indigo-400', icon: '🔵' }, + max: { label: 'Max', color: 'bg-purple-50 dark:bg-purple-950 text-purple-600 dark:text-purple-400', icon: '🟣' }, +}; + +export default async function AdminChannelsPage() { + await requireAdminAuth(); + let channels = []; + try { channels = await adminListChannels(); } catch {} + + return ( +
+
+
+

Каналы

+

Публикация контента zeropost.ru в соцсети

+
+ + + Добавить канал + +
+ + {channels.length === 0 ? ( +
+
📢
+

Каналов пока нет

+

Добавьте Telegram-канал, страницу ВКонтакте или канал Max
чтобы публиковать статьи zeropost.ru в один клик

+ + + Добавить первый канал + +
+ ) : ( +
+ {channels.map(ch => { + const pl = PLATFORM_LABELS[ch.platform] || PLATFORM_LABELS.telegram; + return ( + +
+
+ {pl.icon} +
+ + {ch.is_active ? 'Активен' : 'Неактивен'} + +
+

+ {ch.name} +

+

+ {pl.label} + {ch.tg_username && ` · @${ch.tg_username}`} + {ch.vk_group_id && ` · id${ch.vk_group_id}`} +

+
+ Опубликовать +
+ + ); + })} +
+ )} +
+ ); +} diff --git a/app/admin/api/channels/[id]/publish/route.js b/app/admin/api/channels/[id]/publish/route.js new file mode 100644 index 0000000..ba21eed --- /dev/null +++ b/app/admin/api/channels/[id]/publish/route.js @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +import { adminPublishToChannel } from '@/lib/engine'; + +export async function POST(req, { params }) { + if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + const body = await req.json(); + try { + const data = await adminPublishToChannel(id, body); + return NextResponse.json(data); + } catch (e) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/admin/api/channels/[id]/route.js b/app/admin/api/channels/[id]/route.js new file mode 100644 index 0000000..fc6038a --- /dev/null +++ b/app/admin/api/channels/[id]/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +import { adminUpdateChannel, adminDeleteChannel } 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 data = await adminUpdateChannel(id, body); + return NextResponse.json(data); +} + +export async function DELETE(req, { params }) { + if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + const data = await adminDeleteChannel(id); + return NextResponse.json(data); +} diff --git a/app/admin/api/channels/route.js b/app/admin/api/channels/route.js new file mode 100644 index 0000000..c0c9bde --- /dev/null +++ b/app/admin/api/channels/route.js @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +import { adminListChannels, adminCreateChannel } from '@/lib/engine'; + +export async function GET() { + if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const data = await adminListChannels(); + return NextResponse.json(data); +} + +export async function POST(req) { + if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await req.json(); + const data = await adminCreateChannel(body); + return NextResponse.json(data); +} diff --git a/components/admin/AdminNav.js b/components/admin/AdminNav.js index e76e1d4..9adf547 100644 --- a/components/admin/AdminNav.js +++ b/components/admin/AdminNav.js @@ -1,11 +1,12 @@ 'use client'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { LayoutDashboard, FileText, LogOut, ExternalLink } from 'lucide-react'; +import { LayoutDashboard, FileText, Radio, LogOut, ExternalLink } from 'lucide-react'; const NAV = [ { href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true }, { href: '/admin/articles', label: 'Статьи', icon: FileText }, + { href: '/admin/channels', label: 'Каналы', icon: Radio }, ]; export default function AdminNav() { diff --git a/components/admin/ChannelEditor.js b/components/admin/ChannelEditor.js new file mode 100644 index 0000000..1185d95 --- /dev/null +++ b/components/admin/ChannelEditor.js @@ -0,0 +1,326 @@ +'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Save, Trash2, Send, Plus, ExternalLink } from 'lucide-react'; + +const PLATFORMS = [ + { value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' }, + { value: 'vk', label: 'ВКонтакте', desc: 'Публикация на стену группы' }, + { value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' }, +]; + +export default function ChannelEditor({ channel, articles = [] }) { + const router = useRouter(); + const isNew = !channel; + + const [platform, setPlatform] = useState(channel?.platform || 'telegram'); + const [name, setName] = useState(channel?.name || ''); + const [botToken, setBotToken] = useState(channel?.bot_token || ''); + const [tgChannelId, setTgChannelId] = useState(channel?.tg_channel_id || ''); + const [tgUsername, setTgUsername] = useState(channel?.tg_username || ''); + const [vkGroupId, setVkGroupId] = useState(channel?.vk_group_id || ''); + const [vkToken, setVkToken] = useState(channel?.vk_access_token || ''); + const [maxChannelId, setMaxChannelId] = useState(channel?.max_channel_id || ''); + const [maxToken, setMaxToken] = useState(channel?.max_access_token || ''); + const [isActive, setIsActive] = useState(channel?.is_active ?? true); + + // Публикация + const [selectedArticle, setSelectedArticle] = useState(''); + const [customText, setCustomText] = useState(''); + const [publishing, setPublishing] = useState(false); + const [publishResult, setPublishResult] = useState(null); + + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [toast, setToast] = useState(null); + + function showToast(msg, type = 'success') { + setToast({ msg, type }); + setTimeout(() => setToast(null), 4000); + } + + async function save() { + if (!name.trim()) return showToast('Укажите название канала', 'error'); + setSaving(true); + try { + const body = { + name: name.trim(), platform, is_active: isActive, + bot_token: botToken.trim() || null, + tg_channel_id: tgChannelId.trim() || null, + tg_username: tgUsername.trim().replace('@', '') || null, + vk_group_id: vkGroupId.trim() || null, + vk_access_token: vkToken.trim() || null, + max_channel_id: maxChannelId.trim() || null, + max_access_token: maxToken.trim() || null, + }; + const url = isNew ? '/admin/api/channels' : `/admin/api/channels/${channel.id}`; + const method = isNew ? 'POST' : 'PATCH'; + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + const saved = await res.json(); + showToast(isNew ? 'Канал создан' : 'Сохранено'); + if (isNew) router.push(`/admin/channels/${saved.id}`); + else router.refresh(); + } catch (e) { + showToast(e.message.slice(0, 120), 'error'); + } finally { + setSaving(false); + } + } + + async function deleteChannel() { + if (!confirm('Удалить канал? История публикаций тоже удалится.')) return; + setDeleting(true); + try { + await fetch(`/admin/api/channels/${channel.id}`, { method: 'DELETE' }); + router.push('/admin/channels'); + } catch (e) { + showToast(e.message, 'error'); + setDeleting(false); + } + } + + async function publish() { + if (!selectedArticle && !customText.trim()) { + return showToast('Выберите статью или введите текст', 'error'); + } + setPublishing(true); + setPublishResult(null); + try { + const res = await fetch(`/admin/api/channels/${channel.id}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + article_id: selectedArticle ? parseInt(selectedArticle) : undefined, + custom_text: customText.trim() || undefined, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Ошибка публикации'); + setPublishResult({ ok: true, data }); + showToast('Опубликовано!'); + setSelectedArticle(''); + setCustomText(''); + } catch (e) { + setPublishResult({ ok: false, error: e.message }); + showToast(e.message.slice(0, 120), 'error'); + } finally { + setPublishing(false); + } + } + + // Автозаполнение текста при выборе статьи + function onArticleSelect(artId) { + setSelectedArticle(artId); + if (!artId) { setCustomText(''); return; } + const art = articles.find(a => String(a.id) === artId); + if (art) { + setCustomText(`${art.title}\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`); + } + } + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Шапка */} +
+
+ + + +

+ {isNew ? 'Новый канал' : channel.name} +

+
+
+ {!isNew && ( + + )} + +
+
+ + {/* Настройки */} +
+

Основное

+ + {/* Платформа */} +
+ +
+ {PLATFORMS.map(p => ( + + ))} +
+
+ + {/* Название */} +
+ + setName(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-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="ZeroPost TG" /> +
+ + {/* Поля Telegram */} + {platform === 'telegram' && ( +
+

+ Создайте бота через @BotFather, добавьте его в канал как администратора. +

+
+ + setBotToken(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="123456789:AABBccdd..." /> +
+
+
+ + setTgChannelId(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="-100123456789" /> +
+
+ + setTgUsername(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="@zeropost_ru" /> +
+
+
+ )} + + {/* Поля ВКонтакте */} + {platform === 'vk' && ( +
+

+ Получите токен через VK API с правами wall, photos. +

+
+ + setVkGroupId(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="123456789" /> +
+
+ + setVkToken(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="vk1.a.xxx..." /> +
+
+ )} + + {/* Поля Max */} + {platform === 'max' && ( +
+

+ Max (бывший ОК) — публикация через Bot API. +

+
+ + setMaxChannelId(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="channel_id" /> +
+
+ + setMaxToken(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-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="token..." /> +
+
+ )} + + {/* Активность */} +
+ setIsActive(e.target.checked)} + className="w-4 h-4 rounded accent-emerald-500" /> + +
+
+ + {/* Публикация — только для существующего канала */} + {!isNew && ( +
+

Опубликовать

+ + {/* Выбор статьи */} +
+ + +
+ + {/* Текст поста */} +
+ +