feat: onboarding + topic bank UI + channel limit handling
/onboarding: 3-шаговый вайзард (платформа → название/ниша → готово)
login/page.js: новый пользователь → /onboarding, существующий → /
TopicBank.js: просмотр/пополнение/добавление/удаление тем
ChannelEdit AI-стиль: TopicBank компонент внизу вкладки
channels/new: при 402 CHANNEL_LIMIT_REACHED → ошибка + redirect /plans
lib/engine.js: ENGINE_URL дефолт 3040 → 3030
API routes: /api/topics-bank/[channelId]/{refill,add}, /item/[id]
This commit is contained in:
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette, Plus, X, Sparkles, Plug } from 'lucide-react';
|
||||
import TopicBank from './TopicBank';
|
||||
|
||||
const GOALS = [
|
||||
{ v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' },
|
||||
@@ -492,6 +493,9 @@ export default function ChannelEdit({ channel }) {
|
||||
Единственная модель для генерации изображений. Параметр качества фиксирован провайдером.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Банк тем */}
|
||||
<TopicBank channelId={channel.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Plus, Trash2, Loader2, Lightbulb, X } from 'lucide-react';
|
||||
|
||||
export default function TopicBank({ channelId }) {
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refilling,setRefilling]= useState(false);
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/topics-bank/${channelId}`).then(r => r.json());
|
||||
setTopics(res.topics || []);
|
||||
setTotal(res.total_unused || 0);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [channelId]);
|
||||
|
||||
async function refill() {
|
||||
setRefilling(true);
|
||||
await fetch(`/api/topics-bank/${channelId}/refill`, { method: 'POST' });
|
||||
await load();
|
||||
setRefilling(false);
|
||||
}
|
||||
|
||||
async function addManual() {
|
||||
if (!newTopic.trim()) return;
|
||||
setAdding(true);
|
||||
await fetch(`/api/topics-bank/${channelId}/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topics: [newTopic.trim()] }),
|
||||
});
|
||||
setNewTopic('');
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
async function deleteTopic(id) {
|
||||
await fetch(`/api/topics-bank/item/${id}`, { method: 'DELETE' });
|
||||
setTopics(t => t.filter(x => x.id !== id));
|
||||
setTotal(n => Math.max(0, n - 1));
|
||||
}
|
||||
|
||||
const unusedTopics = topics.filter(t => !t.is_used);
|
||||
const stockColor = total >= 5 ? 'text-green-400' : total > 0 ? 'text-yellow-400' : 'text-red-400';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Lightbulb className="w-4 h-4 text-accent" /> Банк тем
|
||||
</h3>
|
||||
<p className={`text-xs mt-0.5 ${stockColor}`}>
|
||||
{total === 0 ? 'Темы закончились — нужно пополнить' : `${total} тем в запасе`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setShowAdd(v => !v)} className="btn-ghost p-2" title="Добавить тему">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={refill} disabled={refilling} className="btn-ghost p-2" title="Сгенерировать темы AI">
|
||||
<RefreshCw className={`w-4 h-4 ${refilling ? 'animate-spin text-accent' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Добавить вручную */}
|
||||
{showAdd && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newTopic}
|
||||
onChange={e => setNewTopic(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addManual()}
|
||||
placeholder="Введите тему поста..."
|
||||
className="input flex-1 text-sm py-1.5"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={addManual} disabled={adding || !newTopic.trim()} className="btn-primary px-3 py-1.5 text-sm">
|
||||
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Добавить'}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setNewTopic(''); }} className="btn-ghost p-2">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="py-4 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto text-accent" /></div>}
|
||||
|
||||
{!loading && unusedTopics.length === 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
Тем нет. Нажмите ↻ чтобы сгенерировать AI
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && unusedTopics.length > 0 && (
|
||||
<ul className="space-y-1.5 max-h-64 overflow-y-auto">
|
||||
{unusedTopics.map(t => (
|
||||
<li key={t.id} className="flex items-start gap-2 text-sm group">
|
||||
<span className="flex-1 text-gray-300 leading-snug">{t.topic}</span>
|
||||
<button
|
||||
onClick={() => deleteTopic(t.id)}
|
||||
className="opacity-0 group-hover:opacity-100 btn-ghost p-1 shrink-0 text-gray-500 hover:text-red-400 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Темы используются при автоматической генерации постов. При запасе <5 — AI пополняет автоматически.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user