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 }) {
{/* Расписание */}
-
-
-
Статей в день:
-
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 => {n} )}
-
- {s.next_run_at && (
-
- след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
-
- )}
+
+
+
+ Статей в день:
+ 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 => {n} )}
+
+
+
+
+ Время запуска:
+ 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"
+ >
+ {Array.from({length: 24}, (_,i) => (
+ {String(i).padStart(2,'0')}:__
+ ))}
+
+ 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 => (
+ __:{String(m).padStart(2,'0')}
+ ))}
+
+ {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 => (
+ 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}
+
+ ))}
+
+ )}
+
+ {/* Вкладка: Настройки */}
+ {(isNew || activeTab === 'settings') && (
+
Основное
{/* Платформа */}
@@ -272,9 +331,76 @@ export default function ChannelEditor({ channel, articles = [] }) {
Канал активен
+ )}
- {/* Публикация — только для существующего канала */}
- {!isNew && (
+ {/* Вкладка: Расписание */}
+ {!isNew && activeTab === 'schedule' && (
+