feat: drafts UI — /drafts review page + batch generate button
/drafts page: список черновиков по статусам (pending/approved/rejected)
Одобрить + выбрать время → scheduled_post в календарь
Редактировать текст inline, отклонить, удалить
Header: ссылка 'Черновики' (FileText иконка)
ChannelView: кнопка 'Авто ×N' для batch-генерации (async)
ChannelEdit AI-стиль: секция авто-черновиков (toggle + count + time)
API routes: /api/drafts, /api/drafts/[id]/{approve,reject}
/api/channels/[channelId]/drafts/generate
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
export async function POST(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const res = await fetch(
|
||||||
|
`${ENGINE_URL}/api/channels/${params.channelId}/drafts/generate?count=${searchParams.get('count') || body.count || 3}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
export async function POST(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
export async function POST(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||||
|
});
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
function h(userId) {
|
||||||
|
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/drafts/:id
|
||||||
|
export async function PATCH(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const body = await req.json();
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...h(user.id), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/drafts/:id
|
||||||
|
export async function DELETE(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}`, { method: 'DELETE', headers: h(user.id) });
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
function eHeaders(userId) {
|
||||||
|
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/drafts — все черновики пользователя
|
||||||
|
export async function GET(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/drafts?${searchParams}`, { headers: eHeaders(user.id) });
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Clock, Check, X, Edit3, Trash2, RefreshCw, Loader2, Calendar, Image as ImgIcon, Zap } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ v: 'pending', label: 'Ожидают', color: 'text-accent' },
|
||||||
|
{ v: 'approved', label: 'Одобрено', color: 'text-green-400' },
|
||||||
|
{ v: 'rejected', label: 'Отклонено', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function timeAgo(s) {
|
||||||
|
const d = new Date(s), now = new Date();
|
||||||
|
const diff = now - d;
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
|
||||||
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'ч назад';
|
||||||
|
return d.toLocaleDateString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraftsPage() {
|
||||||
|
const [tab, setTab] = useState('pending');
|
||||||
|
const [drafts, setDrafts] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading]= useState(true);
|
||||||
|
const [editing, setEditing]= useState(null); // draft id
|
||||||
|
const [editText,setEditText]=useState('');
|
||||||
|
const [schedMap,setSchedMap]=useState({}); // draftId → scheduledAt
|
||||||
|
const [busy, setBusy] = useState({});
|
||||||
|
|
||||||
|
const load = useCallback(async (t = tab) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/drafts?status=${t}&limit=50`).then(r => r.json());
|
||||||
|
setDrafts(res.drafts || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => { load(tab); }, [tab]);
|
||||||
|
|
||||||
|
async function doApprove(id) {
|
||||||
|
setBusy(b => ({ ...b, [id]: true }));
|
||||||
|
const res = await fetch(`/api/drafts/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ scheduled_at: schedMap[id] || null }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
setBusy(b => ({ ...b, [id]: false }));
|
||||||
|
if (res.ok) load(tab); else alert(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReject(id) {
|
||||||
|
setBusy(b => ({ ...b, [id]: true }));
|
||||||
|
await fetch(`/api/drafts/${id}/reject`, { method: 'POST' });
|
||||||
|
setBusy(b => ({ ...b, [id]: false }));
|
||||||
|
load(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete(id) {
|
||||||
|
if (!confirm('Удалить черновик?')) return;
|
||||||
|
await fetch(`/api/drafts/${id}`, { method: 'DELETE' });
|
||||||
|
load(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id) {
|
||||||
|
await fetch(`/api/drafts/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: editText }),
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
load(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = tab === 'pending' ? total : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-accent" /> Черновики
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-0.5">
|
||||||
|
{tab === 'pending' && total > 0
|
||||||
|
? `${total} ${total === 1 ? 'пост ждёт' : 'поста ждут'} одобрения`
|
||||||
|
: 'Авто-генерированные и пакетные посты на проверку'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load(tab)} className="btn-ghost p-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Табы */}
|
||||||
|
<div className="flex gap-1 mb-5">
|
||||||
|
{STATUS_TABS.map(t => (
|
||||||
|
<button key={t.v} onClick={() => setTab(t.v)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
tab === t.v ? `bg-accent/10 ${t.color} font-medium` : 'text-gray-500 hover:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||||
|
|
||||||
|
{!loading && drafts.length === 0 && (
|
||||||
|
<div className="py-16 text-center text-gray-500">
|
||||||
|
<Zap className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||||
|
<div className="text-sm">
|
||||||
|
{tab === 'pending'
|
||||||
|
? <>Нет черновиков. Включите авто-генерацию в <Link href="/" className="text-accent hover:underline">настройках канала</Link> или сгенерируйте вручную.</>
|
||||||
|
: 'Нет записей'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{drafts.map(draft => (
|
||||||
|
<div key={draft.id} className="card p-4 space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium text-gray-300">{draft.channel_name}</span>
|
||||||
|
{draft.platform && <span className="text-xs text-gray-500 px-1.5 py-0.5 bg-surface2 rounded">{draft.platform}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{timeAgo(draft.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тема */}
|
||||||
|
{draft.topic && (
|
||||||
|
<div className="text-xs text-accent/80 flex items-center gap-1">
|
||||||
|
<span>💡</span> {draft.topic}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Текст */}
|
||||||
|
{editing === draft.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
value={editText}
|
||||||
|
onChange={e => setEditText(e.target.value)}
|
||||||
|
className="input w-full text-sm resize-none font-mono"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => saveEdit(draft.id)} className="btn-primary text-sm px-3 py-1.5">Сохранить</button>
|
||||||
|
<button onClick={() => setEditing(null)} className="btn-ghost text-sm px-3 py-1.5">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-200 whitespace-pre-wrap leading-relaxed bg-surface2 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||||||
|
{draft.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Изображение */}
|
||||||
|
{draft.image_url && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<ImgIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Картинка прикреплена</span>
|
||||||
|
<a href={draft.image_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">просмотр</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
{draft.status === 'pending' && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
{/* Время публикации */}
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={schedMap[draft.id] || ''}
|
||||||
|
onChange={e => setSchedMap(m => ({ ...m, [draft.id]: e.target.value }))}
|
||||||
|
className="input text-xs py-1.5 w-48"
|
||||||
|
title="Время публикации (оставьте пустым для ближайшего слота)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button onClick={() => doApprove(draft.id)} disabled={busy[draft.id]}
|
||||||
|
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||||
|
{busy[draft.id] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||||
|
Одобрить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => { setEditing(draft.id); setEditText(draft.text); }}
|
||||||
|
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||||
|
<Edit3 className="w-3.5 h-3.5" /> Редактировать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => doReject(draft.id)} disabled={busy[draft.id]}
|
||||||
|
className="btn-ghost text-sm px-3 py-1.5 text-gray-500 flex items-center gap-1.5">
|
||||||
|
<X className="w-3.5 h-3.5" /> Отклонить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.status === 'approved' && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-green-400">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Одобрен · запланирован на {draft.scheduled_at ? new Date(draft.scheduled_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.status === 'rejected' && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">Отклонён</span>
|
||||||
|
<button onClick={() => doDelete(draft.id)} className="btn-ghost p-1.5 text-gray-600 hover:text-red-400">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,6 +111,10 @@ export default function ChannelEdit({ channel }) {
|
|||||||
// AI-стиль
|
// AI-стиль
|
||||||
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
|
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
|
||||||
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
|
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
|
||||||
|
// Авто-черновики
|
||||||
|
const [autoDraftEnabled, setAutoDraftEnabled] = useState(channel.auto_draft_enabled || false);
|
||||||
|
const [autoDraftCount, setAutoDraftCount] = useState(channel.auto_draft_count || 3);
|
||||||
|
const [autoDraftTime, setAutoDraftTime] = useState(channel.auto_draft_time || '08:00');
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -129,6 +133,9 @@ export default function ChannelEdit({ channel }) {
|
|||||||
vk_access_token: vkToken.trim() || null,
|
vk_access_token: vkToken.trim() || null,
|
||||||
ai_style_prompt: aiStylePrompt.trim() || null,
|
ai_style_prompt: aiStylePrompt.trim() || null,
|
||||||
image_quality: imageQuality,
|
image_quality: imageQuality,
|
||||||
|
auto_draft_enabled: autoDraftEnabled,
|
||||||
|
auto_draft_count: autoDraftCount,
|
||||||
|
auto_draft_time: autoDraftTime,
|
||||||
style: {
|
style: {
|
||||||
tone, formality, humor,
|
tone, formality, humor,
|
||||||
post_length: postLength,
|
post_length: postLength,
|
||||||
@@ -496,6 +503,54 @@ export default function ChannelEdit({ channel }) {
|
|||||||
|
|
||||||
{/* Банк тем */}
|
{/* Банк тем */}
|
||||||
<TopicBank channelId={channel.id} />
|
<TopicBank channelId={channel.id} />
|
||||||
|
|
||||||
|
{/* Авто-черновики */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<span>⚡</span> Авто-генерация черновиков
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Система генерирует посты каждый день — ты одобряешь вечером
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer"
|
||||||
|
checked={autoDraftEnabled}
|
||||||
|
onChange={e => setAutoDraftEnabled(e.target.checked)} />
|
||||||
|
<div className="w-10 h-5 bg-gray-600 peer-focus:outline-none rounded-full peer
|
||||||
|
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||||
|
after:content-[''] after:absolute after:top-0.5 after:left-[2px]
|
||||||
|
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
|
||||||
|
peer-checked:bg-accent" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{autoDraftEnabled && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs mb-1">Постов в день</label>
|
||||||
|
<select value={autoDraftCount} onChange={e => setAutoDraftCount(+e.target.value)}
|
||||||
|
className="input w-full text-sm py-1.5">
|
||||||
|
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n} {n===1?'пост':'постов'}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs mb-1">Время генерации</label>
|
||||||
|
<input type="time" value={autoDraftTime}
|
||||||
|
onChange={e => setAutoDraftTime(e.target.value)}
|
||||||
|
className="input w-full text-sm py-1.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Черновики появляются на странице{' '}
|
||||||
|
<a href="/drafts" target="_blank" className="text-accent hover:underline">Черновики</a>.
|
||||||
|
Там можно редактировать, одобрять и планировать публикацию.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default function ChannelView({ channel }) {
|
|||||||
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
|
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
|
||||||
const [showFromUrl, setShowFromUrl] = useState(false);
|
const [showFromUrl, setShowFromUrl] = useState(false);
|
||||||
const [showPoll, setShowPoll] = useState(false);
|
const [showPoll, setShowPoll] = useState(false);
|
||||||
|
const [batchCount, setBatchCount] = useState(3);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
// Трансформации
|
// Трансформации
|
||||||
const [transforming, setTransforming] = useState(false);
|
const [transforming, setTransforming] = useState(false);
|
||||||
@@ -400,6 +402,31 @@ export default function ChannelView({ channel }) {
|
|||||||
Опрос
|
Опрос
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Batch-генерация черновиков */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setBatchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channel.id}/drafts/generate?count=${batchCount}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (res.ok) alert(`✅ Генерирую ${batchCount} черновиков — через несколько минут появятся в /drafts`);
|
||||||
|
else alert(res.error || 'Ошибка');
|
||||||
|
} catch { alert('Ошибка'); }
|
||||||
|
setBatchLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={batchLoading}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{batchLoading ? '⏳' : '⚡'}</span>
|
||||||
|
Авто ×
|
||||||
|
</button>
|
||||||
|
<select value={batchCount} onChange={e => setBatchCount(+e.target.value)}
|
||||||
|
className="text-xs bg-surface2 border border-border rounded px-1 py-0.5 text-gray-400">
|
||||||
|
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={fetchIdeas}
|
onClick={fetchIdeas}
|
||||||
disabled={loadingIdeas}
|
disabled={loadingIdeas}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp, Coins } from 'lucide-react';
|
import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp, Coins, FileText } from 'lucide-react';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
|
||||||
export default function Header({ user }) {
|
export default function Header({ user }) {
|
||||||
@@ -34,6 +34,10 @@ export default function Header({ user }) {
|
|||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
<span>Календарь</span>
|
<span>Календарь</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/drafts" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Черновики</span>
|
||||||
|
</Link>
|
||||||
{user?.isAdmin && (
|
{user?.isAdmin && (
|
||||||
<Link href="/spending" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
<Link href="/spending" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user