feat: admin channels — list, editor, publish panel, TG/VK/Max support
This commit is contained in:
@@ -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={[]} />;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 = [
|
const NAV = [
|
||||||
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
||||||
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
||||||
|
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminNav() {
|
export default function AdminNav() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user