Files
zeropost-web/components/admin/ChannelEditor.js
T
Nik (Claude) 334b2f51df feat: журнальная главная, страница Зеро, TG-баннер, stats, auto-publish UI
- Журнальная главная: hero, CategoryRow, PopularBlock, RecentBlock (Сегодня/Вчера/Неделя)
- ArticleCard: 3 размера (hero/regular/compact), цветной badge без дублей тегов
- ArticleCoverSVG: 6 брендовых палитр, аватар Зеро в углу вместо #ZEROPOST
- /about/zero: страница персонажа с галереей 8 поз
- Footer: TG-баннер с аватаром Зеро на каждой странице
- Конец статьи: блок «Понравилась? → Подписаться на канал»
- ChannelEditor: 4 вкладки (Настройки/Расписание/Авто-публикация/Ручная)
- AutoPublishTab: toggle, категории, delay, template, live preview
- ArticlePicker: typeahead с was_sent_to_channel / next_scheduled_at флагами
- /admin/channels/[id]/stats: график роста подписчиков (recharts)
- Dashboard: блок TG-статистики (подписчики, delta 24h/7d, постов)
- Header: упрощён до 2 пунктов desktop + расширенное мобильное меню
- AutogenPanel: корректные time-picker'ы, calcNextRun с учётом last_run_at
2026-06-07 14:04:09 +03:00

567 lines
29 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, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Save, Trash2, Send, Plus, Clock, X, Sparkles, RefreshCw, BarChart2 } from 'lucide-react';
import ArticlePicker from './ArticlePicker';
import AutoPublishTab from './AutoPublishTab';
const PLATFORMS = [
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
{ value: 'vk', label: 'ВКонтакте', desc: 'Публикация на стену группы' },
{ value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' },
];
const TABS = [
{ id: 'settings', label: 'Настройки' },
{ id: 'schedule', label: 'Расписание' },
{ id: 'autopublish', label: 'Авто-публикация' },
{ id: 'publish', label: 'Ручная публикация' },
];
export default function ChannelEditor({ channel }) {
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);
// Ручная публикация: либо статья, либо custom_text. Опционально — на конкретное время (или сейчас).
const [pickedArticle, setPickedArticle] = useState(null);
const [customText, setCustomText] = useState('');
const [publishMode, setPublishMode] = useState('now'); // 'now' | 'schedule'
const [scheduleAt, setScheduleAt] = 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);
const [activeTab, setActiveTab] = useState('settings');
// Слоты
const [slots, setSlots] = useState([]);
const [newSlotH, setNewSlotH] = useState(8);
const [newSlotM, setNewSlotM] = useState(0);
const [addingSlot, setAddingSlot] = useState(false);
// Очередь канала
const [queue, setQueue] = useState([]);
const [loadingQueue, setLoadingQueue] = useState(false);
useEffect(() => {
if (!channel?.id) return;
fetch(`/admin/api/channels/${channel.id}/slots`).then(r => r.json()).then(setSlots).catch(() => {});
loadQueue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel?.id]);
async function loadQueue() {
if (!channel?.id) return;
setLoadingQueue(true);
try {
const r = await fetch(`/admin/api/channels/${channel.id}/scheduled`);
const d = await r.json();
if (Array.isArray(d)) setQueue(d);
} catch {} finally { setLoadingQueue(false); }
}
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 doPublish() {
if (!pickedArticle && !customText.trim()) {
return showToast('Выберите статью или введите текст', 'error');
}
setPublishing(true);
setPublishResult(null);
try {
const isSchedule = publishMode === 'schedule';
if (isSchedule && !scheduleAt) {
throw new Error('Укажите время публикации');
}
const url = isSchedule
? `/admin/api/channels/${channel.id}/scheduled`
: `/admin/api/channels/${channel.id}/publish`;
const body = isSchedule
? {
article_id: pickedArticle?.id,
custom_text: customText.trim() || undefined,
scheduled_at: new Date(scheduleAt).toISOString(),
}
: {
article_id: pickedArticle?.id,
custom_text: customText.trim() || undefined,
};
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка публикации');
setPublishResult({ ok: true, data, scheduled: isSchedule });
showToast(isSchedule ? `Запланировано на ${new Date(scheduleAt).toLocaleString('ru-RU')}` : 'Опубликовано!');
setPickedArticle(null);
setCustomText('');
setScheduleAt('');
loadQueue();
} catch (e) {
setPublishResult({ ok: false, error: e.message });
showToast(e.message.slice(0, 120), 'error');
} finally {
setPublishing(false);
}
}
async function cancelScheduled(id) {
if (!confirm('Отменить эту запланированную публикацию?')) return;
try {
await fetch(`/admin/api/scheduled/${id}`, { method: 'DELETE' });
loadQueue();
showToast('Отменено');
} catch (e) {
showToast(e.message, 'error');
}
}
async function addSlot() {
if (!channel?.id) return;
setAddingSlot(true);
try {
const res = await fetch(`/admin/api/channels/${channel.id}/slots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot_hour: newSlotH, slot_minute: newSlotM }),
});
if (!res.ok) throw new Error(await res.text());
const slot = await res.json();
setSlots(s => [...s, slot].sort((a,b) => a.slot_hour*60+a.slot_minute - (b.slot_hour*60+b.slot_minute)));
showToast('Слот добавлен');
} catch (e) { showToast(e.message, 'error'); }
finally { setAddingSlot(false); }
}
async function removeSlot(slotId) {
await fetch(`/admin/api/channels/${channel.id}/slots/${slotId}`, { method: 'DELETE' });
setSlots(s => s.filter(sl => sl.id !== slotId));
}
const STATUS_LABELS = {
pending: { text: 'В очереди', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300' },
sent: { text: 'Отправлено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' },
failed: { text: 'Ошибка', cls: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300' },
};
return (
<div className="space-y-6 max-w-3xl">
{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>
{!isNew && channel.auto_publish_enabled && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300">
<Sparkles className="w-3 h-3" />
auto
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isNew && (
<Link href={`/admin/channels/${channel.id}/stats`}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors">
<BarChart2 className="w-4 h-4" />
Статистика
</Link>
)}
{!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>
)}
{(isNew || activeTab === 'settings') && (
<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>
{!isNew && (
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-800 overflow-x-auto">
{TABS.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}>
{tab.label}
</button>
))}
</div>
)}
{/* Вкладка: Настройки */}
{(isNew || activeTab === 'settings') && (
<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>
{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>
)}
{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 && activeTab === 'schedule' && (
<div className="space-y-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Слоты публикации</h2>
<p className="text-xs text-neutral-400 mb-5">Время когда выходят запланированные посты. При автопубликации новая статья ставится на ближайший свободный слот.</p>
<div className="space-y-2 mb-5">
{slots.length === 0 && (
<p className="text-sm text-neutral-400 text-center py-4">Слотов пока нет добавьте время публикации</p>
)}
{slots.map((slot, idx) => (
<div key={slot.id} className="flex items-center gap-3 px-4 py-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<Clock className="w-4 h-4 text-neutral-400 shrink-0" />
<span className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 font-mono w-16">
{String(slot.slot_hour).padStart(2,'0')}:{String(slot.slot_minute).padStart(2,'0')}
</span>
<span className="text-xs text-neutral-400 flex-1">Слот {idx + 1}</span>
<button onClick={() => removeSlot(slot.id)}
className="p-1 rounded text-neutral-300 hover:text-red-500 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
<div className="border-t border-neutral-100 dark:border-neutral-800 pt-4">
<p className="text-xs font-medium text-neutral-500 mb-3">Добавить слот</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg px-4 py-2.5 border border-neutral-200 dark:border-neutral-700">
<select value={newSlotH} onChange={e => setNewSlotH(parseInt(e.target.value))}
className="bg-transparent text-lg font-bold font-mono text-neutral-900 dark:text-neutral-100 focus:outline-none w-12">
{Array.from({length:24},(_,i)=>(
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
))}
</select>
<span className="text-lg font-bold text-neutral-400">:</span>
<select value={newSlotM} onChange={e => setNewSlotM(parseInt(e.target.value))}
className="bg-transparent text-lg font-bold font-mono text-neutral-900 dark:text-neutral-100 focus:outline-none w-12">
{Array.from({length:60},(_,i)=>(
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
))}
</select>
</div>
<button onClick={addSlot} disabled={addingSlot}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
</div>
</div>
{/* Очередь канала */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Очередь канала</h2>
<button onClick={loadQueue} className="text-xs text-neutral-500 hover:text-emerald-600 inline-flex items-center gap-1">
<RefreshCw className={`w-3.5 h-3.5 ${loadingQueue ? 'animate-spin' : ''}`} /> Обновить
</button>
</div>
{queue.length === 0 ? (
<p className="text-sm text-neutral-400 text-center py-4">Очередь пуста</p>
) : (
<div className="space-y-2">
{queue.map(q => {
const label = STATUS_LABELS[q.status] || STATUS_LABELS.pending;
return (
<div key={q.id} className="flex items-start gap-3 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
{q.article_title || (q.custom_text?.slice(0, 60) + '...') || `Пост #${q.id}`}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-neutral-500 flex-wrap">
<span className={`px-1.5 py-0.5 rounded font-medium ${label.cls}`}>{label.text}</span>
<span>{new Date(q.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
{q.article_category && <span className="text-neutral-400">[{q.article_category}]</span>}
{q.error && <span className="text-red-500 truncate">{q.error}</span>}
</div>
</div>
{q.status === 'pending' && (
<button onClick={() => cancelScheduled(q.id)}
className="text-xs text-neutral-400 hover:text-red-500 px-2 py-1">
отменить
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{/* Вкладка: Авто-публикация */}
{!isNew && activeTab === 'autopublish' && (
<AutoPublishTab channel={channel} onSaved={() => router.refresh()} />
)}
{/* Вкладка: Ручная публикация */}
{!isNew && activeTab === 'publish' && (
<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>
<ArticlePicker
value={pickedArticle?.id}
onChange={a => {
setPickedArticle(a);
if (a) setCustomText(''); // когда выбрана статья — текст рендерит шаблон канала
}}
channelId={channel.id}
placeholder="Найти статью (поиск по названию)…"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">
Или произвольный текст {pickedArticle && <span className="text-neutral-400">(если задан перебивает шаблон статьи)</span>}
</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>
{/* Режим */}
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Когда</label>
<div className="flex gap-2">
<button onClick={() => setPublishMode('now')}
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
publishMode === 'now'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600'
}`}>
<Send className="w-3.5 h-3.5 inline mr-1" /> Сейчас
</button>
<button onClick={() => setPublishMode('schedule')}
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
publishMode === 'schedule'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600'
}`}>
<Clock className="w-3.5 h-3.5 inline mr-1" /> Запланировать
</button>
</div>
{publishMode === 'schedule' && (
<input
type="datetime-local"
value={scheduleAt}
onChange={e => setScheduleAt(e.target.value)}
min={new Date(Date.now() + 60000).toISOString().slice(0, 16)}
className="mt-2 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 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
)}
</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.scheduled ? 'Запланировано' : 'Опубликовано'}
{publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}
</span>
) : (
<span> {publishResult.error}</span>
)}
</div>
)}
<button onClick={doPublish} disabled={publishing || (!pickedArticle && !customText.trim()) || (publishMode === 'schedule' && !scheduleAt)}
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 ? '…' : (publishMode === 'schedule' ? 'Поставить в очередь' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`)}
</button>
</div>
)}
</div>
);
}