feat: admin channels — list, editor, publish panel, TG/VK/Max support
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user