feat: admin channels — list, editor, publish panel, TG/VK/Max support

This commit is contained in:
Alexey Pavlov
2026-05-31 14:37:50 +03:00
parent b4f5f169cc
commit 80325b4435
9 changed files with 544 additions and 1 deletions
@@ -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 <ChannelEditor channel={channel} articles={articles} />;
}
@@ -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 <ChannelEditor channel={null} articles={[]} />;
}
+89
View File
@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Каналы</h1>
<p className="text-sm text-neutral-500 mt-0.5">Публикация контента zeropost.ru в соцсети</p>
</div>
<Link
href="/admin/channels/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
Добавить канал
</Link>
</div>
{channels.length === 0 ? (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-12 text-center">
<div className="text-4xl mb-4">📢</div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">Каналов пока нет</h2>
<p className="text-sm text-neutral-500 mb-6">Добавьте Telegram-канал, страницу ВКонтакте или канал Max<br/>чтобы публиковать статьи zeropost.ru в один клик</p>
<Link
href="/admin/channels/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
Добавить первый канал
</Link>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map(ch => {
const pl = PLATFORM_LABELS[ch.platform] || PLATFORM_LABELS.telegram;
return (
<Link
key={ch.id}
href={`/admin/channels/${ch.id}`}
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 hover:border-emerald-300 dark:hover:border-emerald-700 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between mb-4">
<div className={`text-2xl w-10 h-10 flex items-center justify-center rounded-xl ${pl.color}`}>
{pl.icon}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
ch.is_active
? 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400'
}`}>
{ch.is_active ? 'Активен' : 'Неактивен'}
</span>
</div>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors mb-1">
{ch.name}
</h3>
<p className="text-xs text-neutral-400 mb-3">
{pl.label}
{ch.tg_username && ` · @${ch.tg_username}`}
{ch.vk_group_id && ` · id${ch.vk_group_id}`}
</p>
<div className="flex items-center gap-3 text-xs text-neutral-400">
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> Опубликовать</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
@@ -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 });
}
}
+18
View File
@@ -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);
}
+16
View File
@@ -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);
}
+2 -1
View File
@@ -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() {
+326
View File
@@ -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 (
<div className="space-y-6 max-w-3xl">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
}`}>
{toast.msg}
</div>
)}
{/* Шапка */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/channels" className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400">
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
{isNew ? 'Новый канал' : channel.name}
</h1>
</div>
<div className="flex items-center gap-2">
{!isNew && (
<button onClick={deleteChannel} disabled={deleting}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors disabled:opacity-50">
<Trash2 className="w-4 h-4" />
{deleting ? 'Удаление...' : 'Удалить'}
</button>
)}
<button onClick={save} disabled={saving}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{/* Настройки */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2>
{/* Платформа */}
<div>
<label className="block text-xs font-medium text-neutral-500 mb-2">Платформа</label>
<div className="grid grid-cols-3 gap-2">
{PLATFORMS.map(p => (
<button
key={p.value}
onClick={() => setPlatform(p.value)}
className={`p-3 rounded-lg border text-left transition-all ${
platform === p.value
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300'
}`}
>
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{p.label}</div>
<div className="text-xs text-neutral-400 mt-0.5">{p.desc}</div>
</button>
))}
</div>
</div>
{/* Название */}
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Название канала</label>
<input type="text" value={name} onChange={e => 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" />
</div>
{/* Поля Telegram */}
{platform === 'telegram' && (
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
<p className="text-xs text-neutral-400">
Создайте бота через <a href="https://t.me/BotFather" target="_blank" className="text-blue-500 hover:underline">@BotFather</a>, добавьте его в канал как администратора.
</p>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Bot Token</label>
<input type="password" value={botToken} onChange={e => 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..." />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
<input type="text" value={tgChannelId} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Username (опц.)</label>
<input type="text" value={tgUsername} onChange={e => 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" />
</div>
</div>
</div>
)}
{/* Поля ВКонтакте */}
{platform === 'vk' && (
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
<p className="text-xs text-neutral-400">
Получите токен через <a href="https://vk.com/dev/implicit_flow_user" target="_blank" className="text-blue-500 hover:underline">VK API</a> с правами wall, photos.
</p>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">ID группы</label>
<input type="text" value={vkGroupId} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
<input type="password" value={vkToken} onChange={e => 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..." />
</div>
</div>
)}
{/* Поля Max */}
{platform === 'max' && (
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
<p className="text-xs text-neutral-400">
Max (бывший ОК) публикация через Bot API.
</p>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
<input type="text" value={maxChannelId} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
<input type="password" value={maxToken} onChange={e => 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..." />
</div>
</div>
)}
{/* Активность */}
<div className="flex items-center gap-2 pt-2">
<input type="checkbox" id="isActive" checked={isActive} onChange={e => setIsActive(e.target.checked)}
className="w-4 h-4 rounded accent-emerald-500" />
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
</div>
</div>
{/* Публикация — только для существующего канала */}
{!isNew && (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2>
{/* Выбор статьи */}
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Выбрать статью</label>
<select value={selectedArticle} onChange={e => onArticleSelect(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">
<option value=""> выбрать статью </option>
{articles.map(a => (
<option key={a.id} value={a.id}>{a.title}</option>
))}
</select>
</div>
{/* Текст поста */}
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Текст поста</label>
<textarea value={customText} onChange={e => setCustomText(e.target.value)} rows={6}
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 resize-y font-mono"
placeholder="Текст поста (Markdown для Telegram)..." />
<div className="text-xs text-neutral-300 text-right mt-0.5">{customText.length} символов</div>
</div>
{/* Результат */}
{publishResult && (
<div className={`rounded-lg px-4 py-3 text-sm ${
publishResult.ok
? 'bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
: 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
}`}>
{publishResult.ok ? (
<span> Опубликовано{publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}</span>
) : (
<span> {publishResult.error}</span>
)}
</div>
)}
<button onClick={publish} disabled={publishing || (!selectedArticle && !customText.trim())}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
<Send className="w-4 h-4" />
{publishing ? 'Публикация...' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`}
</button>
</div>
)}
</div>
);
}
+36
View File
@@ -107,3 +107,39 @@ export async function adminGenerateArticle(topic, tags = []) {
});
}
// ── Admin Channels API ────────────────────────────────────────────────────────
export async function adminListChannels() {
return call('/api/channels/admin');
}
export async function adminCreateChannel(data) {
return call('/api/channels/admin', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function adminUpdateChannel(id, data) {
return call(`/api/channels/admin/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function adminDeleteChannel(id) {
return call(`/api/channels/admin/${id}`, { method: 'DELETE' });
}
export async function adminPublishToChannel(channelId, { article_id, custom_text } = {}) {
return call(`/api/channels/admin/${channelId}/publish`, {
method: 'POST',
body: JSON.stringify({ article_id, custom_text }),
});
}
export async function adminGetChannelPosts(channelId) {
return call(`/api/channels/admin/${channelId}/posts`);
}