From 14e1fd14df9974b05d4d30495cbbbef2ef3d5990 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 16:45:16 +0300 Subject: [PATCH] feat: autogen time picker, channel schedule tab with publish slots --- .../api/channels/[id]/slots/[slotId]/route.js | 11 ++ app/admin/api/channels/[id]/slots/route.js | 19 +++ components/admin/AutogenPanel.js | 60 ++++++-- components/admin/ChannelEditor.js | 138 +++++++++++++++++- 4 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 app/admin/api/channels/[id]/slots/[slotId]/route.js create mode 100644 app/admin/api/channels/[id]/slots/route.js diff --git a/app/admin/api/channels/[id]/slots/[slotId]/route.js b/app/admin/api/channels/[id]/slots/[slotId]/route.js new file mode 100644 index 0000000..40de511 --- /dev/null +++ b/app/admin/api/channels/[id]/slots/[slotId]/route.js @@ -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}); +} diff --git a/app/admin/api/channels/[id]/slots/route.js b/app/admin/api/channels/[id]/slots/route.js new file mode 100644 index 0000000..0cd67d4 --- /dev/null +++ b/app/admin/api/channels/[id]/slots/route.js @@ -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()); +} diff --git a/components/admin/AutogenPanel.js b/components/admin/AutogenPanel.js index 423e3d3..f34b6e2 100644 --- a/components/admin/AutogenPanel.js +++ b/components/admin/AutogenPanel.js @@ -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) { await fetch('/admin/api/autogen/settings', { method: 'PATCH', @@ -170,21 +179,42 @@ export default function AutogenPanel({ status, queue, topics }) { {/* Расписание */} -
- - Статей в день: - - {s.next_run_at && ( - - след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} - - )} +
+
+ + Статей в день: + +
+
+ + Время запуска: + + + {s.next_run_at && ( + + след.: {new Date(s.next_run_at).toLocaleString('ru-RU', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} + + )} +
{/* Кнопка запуска */} diff --git a/components/admin/ChannelEditor.js b/components/admin/ChannelEditor.js index 1185d95..496a6b7 100644 --- a/components/admin/ChannelEditor.js +++ b/components/admin/ChannelEditor.js @@ -1,8 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; 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 = [ { value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' }, @@ -10,6 +10,12 @@ const PLATFORMS = [ { value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' }, ]; +const TABS = [ + { id: 'settings', label: 'Настройки' }, + { id: 'schedule', label: 'Расписание' }, + { id: 'publish', label: 'Публикация' }, +]; + export default function ChannelEditor({ channel, articles = [] }) { const router = useRouter(); const isNew = !channel; @@ -34,6 +40,20 @@ export default function ChannelEditor({ channel, articles = [] }) { 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); + + // Загружаем слоты + 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') { 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 (
{/* Toast */} @@ -161,8 +203,25 @@ export default function ChannelEditor({ channel, articles = [] }) {
- {/* Настройки */} -
+ {/* Вкладки — только для существующего канала */} + {!isNew && ( +
+ {TABS.map(tab => ( + + ))} +
+ )} + + {/* Вкладка: Настройки */} + {(isNew || activeTab === 'settings') && ( +

Основное

{/* Платформа */} @@ -272,9 +331,76 @@ export default function ChannelEditor({ channel, articles = [] }) {
+ )} - {/* Публикация — только для существующего канала */} - {!isNew && ( + {/* Вкладка: Расписание */} + {!isNew && activeTab === 'schedule' && ( +
+
+

Слоты публикации

+

Время когда будут отправляться запланированные посты. Добавьте несколько слотов — посты распределятся по ним автоматически.

+ + {/* Список слотов */} +
+ {slots.length === 0 && ( +

Слотов пока нет — добавьте время публикации

+ )} + {slots.map((slot, idx) => ( +
+ + + {String(slot.slot_hour).padStart(2,'0')}:{String(slot.slot_minute).padStart(2,'0')} + + Слот {idx + 1} + +
+ ))} +
+ + {/* Добавить слот */} +
+

Добавить слот

+
+
+ + : + +
+ +
+

+ Совет: используй не круглые числа (8:06, 11:23) — так посты выглядят органичнее в ленте +

+
+
+ + {/* Подсказка */} +
+

Как работает расписание

+

Когда ты ставишь пост в очередь на вкладке "Публикация" — он автоматически назначается на ближайший свободный слот. Если добавить 4 слота, посты будут выходить 4 раза в день.

+
+
+ )} + + {/* Вкладка: Публикация */} + {!isNew && activeTab === 'publish' && (

Опубликовать