feat: autogen time picker, channel schedule tab with publish slots
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
const E = process.env.ENGINE_URL||'http://127.0.0.1:3030';
|
||||||
|
const S = process.env.ENGINE_SECRET||'zeropost_internal_2026';
|
||||||
|
|
||||||
|
export async function DELETE(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
|
||||||
|
const { id, slotId } = await params;
|
||||||
|
await fetch(`${E}/api/channels/admin/${id}/slots/${slotId}`,{method:'DELETE',headers:{'x-internal-secret':S}});
|
||||||
|
return NextResponse.json({ok:true});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
const E = process.env.ENGINE_URL||'http://127.0.0.1:3030';
|
||||||
|
const S = process.env.ENGINE_SECRET||'zeropost_internal_2026';
|
||||||
|
const h = {'Content-Type':'application/json','x-internal-secret':S};
|
||||||
|
|
||||||
|
export async function GET(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
|
||||||
|
const { id } = await params;
|
||||||
|
const res = await fetch(`${E}/api/channels/admin/${id}/slots`, { headers: h });
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
export async function POST(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const res = await fetch(`${E}/api/channels/admin/${id}/slots`, {method:'POST',headers:h,body:JSON.stringify(body)});
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -68,6 +68,15 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateTime(category, run_hour, run_minute) {
|
||||||
|
await fetch('/admin/api/autogen/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ category, run_hour: parseInt(run_hour), run_minute: parseInt(run_minute) }),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleCategory(category, enabled) {
|
async function toggleCategory(category, enabled) {
|
||||||
await fetch('/admin/api/autogen/settings', {
|
await fetch('/admin/api/autogen/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -170,22 +179,43 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Расписание */}
|
{/* Расписание */}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<Clock className="w-3.5 h-3.5 opacity-60" />
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
|
||||||
<span className="text-xs opacity-70">Статей в день:</span>
|
<span className="text-xs opacity-70">Статей в день:</span>
|
||||||
|
<select value={s.per_day} onChange={e => updatePerDay(s.category, e.target.value)}
|
||||||
|
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5">
|
||||||
|
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
|
||||||
|
<span className="text-xs opacity-70">Время запуска:</span>
|
||||||
<select
|
<select
|
||||||
value={s.per_day}
|
value={s.run_hour ?? 8}
|
||||||
onChange={e => updatePerDay(s.category, e.target.value)}
|
onChange={e => updateTime(s.category, e.target.value, s.run_minute ?? 0)}
|
||||||
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
|
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
|
||||||
>
|
>
|
||||||
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
|
{Array.from({length: 24}, (_,i) => (
|
||||||
|
<option key={i} value={i}>{String(i).padStart(2,'0')}:__</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={s.run_minute ?? 0}
|
||||||
|
onChange={e => updateTime(s.category, s.run_hour ?? 8, e.target.value)}
|
||||||
|
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
|
||||||
|
>
|
||||||
|
{[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
|
||||||
|
<option key={m} value={m}>__:{String(m).padStart(2,'0')}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
{s.next_run_at && (
|
{s.next_run_at && (
|
||||||
<span className="text-xs opacity-60 ml-auto">
|
<span className="text-xs opacity-60 ml-auto">
|
||||||
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
|
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Кнопка запуска */}
|
{/* Кнопка запуска */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Save, Trash2, Send, Plus, ExternalLink } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, Send, Plus, Clock, X } from 'lucide-react';
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
|
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
|
||||||
@@ -10,6 +10,12 @@ const PLATFORMS = [
|
|||||||
{ value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' },
|
{ value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'settings', label: 'Настройки' },
|
||||||
|
{ id: 'schedule', label: 'Расписание' },
|
||||||
|
{ id: 'publish', label: 'Публикация' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ChannelEditor({ channel, articles = [] }) {
|
export default function ChannelEditor({ channel, articles = [] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isNew = !channel;
|
const isNew = !channel;
|
||||||
@@ -34,6 +40,20 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [toast, setToast] = useState(null);
|
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);
|
||||||
|
|
||||||
|
// Загружаем слоты
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channel?.id) return;
|
||||||
|
fetch(`/admin/api/channels/${channel.id}/slots`)
|
||||||
|
.then(r => r.json()).then(setSlots).catch(() => {});
|
||||||
|
}, [channel?.id]);
|
||||||
|
|
||||||
function showToast(msg, type = 'success') {
|
function showToast(msg, type = 'success') {
|
||||||
setToast({ msg, type });
|
setToast({ msg, type });
|
||||||
@@ -124,6 +144,28 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
@@ -161,7 +203,24 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Настройки */}
|
{/* Вкладки — только для существующего канала */}
|
||||||
|
{!isNew && (
|
||||||
|
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
{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 ${
|
||||||
|
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">
|
<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>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2>
|
||||||
|
|
||||||
@@ -272,9 +331,76 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
|
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Публикация — только для существующего канала */}
|
{/* Вкладка: Расписание */}
|
||||||
{!isNew && (
|
{!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>
|
||||||
|
<p className="text-xs text-neutral-400 mt-2">
|
||||||
|
Совет: используй не круглые числа (8:06, 11:23) — так посты выглядят органичнее в ленте
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl p-4 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p className="font-medium mb-1">Как работает расписание</p>
|
||||||
|
<p className="text-xs opacity-80">Когда ты ставишь пост в очередь на вкладке "Публикация" — он автоматически назначается на ближайший свободный слот. Если добавить 4 слота, посты будут выходить 4 раза в день.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Вкладка: Публикация */}
|
||||||
|
{!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">
|
<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>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user