327 lines
16 KiB
JavaScript
327 lines
16 KiB
JavaScript
'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>
|
||
);
|
||
}
|