Files
zeropost-web/components/admin/ChannelEditor.js
T

327 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}