334b2f51df
- Журнальная главная: 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
567 lines
29 KiB
JavaScript
567 lines
29 KiB
JavaScript
'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>
|
||
);
|
||
}
|