feat: PollModal + HashtagSuggest UI
PollModal:
- Вопрос + 2-10 вариантов ответа
- Типы: обычный / викторина (с правильным ответом и объяснением)
- Настройки: анонимность, несколько ответов
- Отложенная публикация через datetime-local
- Кнопка «📊 Опрос» в ChannelView (только для TG каналов)
HashtagSuggest:
- Появляется под сгенерированным постом
- Запрос в /api/generate/hashtags → Claude Haiku
- Клик по тегу — выбор, кнопка «Добавить в пост»
- Обновление тегов, закрытие панели
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost: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();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/channels/${params.id}/poll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-secret': ENGINE_SECRET,
|
||||||
|
'x-user-id': String(user.id),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const body = await req.json();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ENGINE_URL}/api/generate/hashtags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-secret': ENGINE_SECRET,
|
||||||
|
'x-user-id': String(user.id),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import PostPreview from './PostPreview';
|
|||||||
import PostTemplates from './PostTemplates';
|
import PostTemplates from './PostTemplates';
|
||||||
import ChannelAnalytics from './ChannelAnalytics';
|
import ChannelAnalytics from './ChannelAnalytics';
|
||||||
import FromUrlModal from './FromUrlModal';
|
import FromUrlModal from './FromUrlModal';
|
||||||
|
import PollModal from './PollModal';
|
||||||
|
import HashtagSuggest from './HashtagSuggest';
|
||||||
|
|
||||||
const GOAL_LABELS = {
|
const GOAL_LABELS = {
|
||||||
educational: 'Обучение', news: 'Новости',
|
educational: 'Обучение', news: 'Новости',
|
||||||
@@ -58,6 +60,7 @@ export default function ChannelView({ channel }) {
|
|||||||
// Photo search modal
|
// Photo search modal
|
||||||
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 [transforming, setTransforming] = useState(false);
|
const [transforming, setTransforming] = useState(false);
|
||||||
@@ -383,6 +386,15 @@ export default function ChannelView({ channel }) {
|
|||||||
<Link2 className="w-3.5 h-3.5" />
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
По ссылке
|
По ссылке
|
||||||
</button>
|
</button>
|
||||||
|
{channel.platform === 'telegram' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPoll(true)}
|
||||||
|
className="text-xs inline-flex items-center gap-1 text-gray-400 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
Опрос
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={fetchIdeas}
|
onClick={fetchIdeas}
|
||||||
disabled={loadingIdeas}
|
disabled={loadingIdeas}
|
||||||
@@ -526,7 +538,12 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Картинка к посту */}
|
{/* Хештеги */}
|
||||||
|
<HashtagSuggest
|
||||||
|
channelId={channel.id}
|
||||||
|
postText={post}
|
||||||
|
onAppend={text => setPost(p => (p || '') + text)}
|
||||||
|
/>
|
||||||
{image && (
|
{image && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -699,6 +716,18 @@ export default function ChannelView({ channel }) {
|
|||||||
onApply={applyFromUrl}
|
onApply={applyFromUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showPoll && (
|
||||||
|
<PollModal
|
||||||
|
channel={channel}
|
||||||
|
onClose={() => setShowPoll(false)}
|
||||||
|
onPublished={r => {
|
||||||
|
setShowPoll(false);
|
||||||
|
// Уведомление
|
||||||
|
if (r.scheduled) alert(`Опрос запланирован на ${new Date(r.scheduled_at).toLocaleString('ru-RU')}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Photo search modal */}
|
{/* Photo search modal */}
|
||||||
<PhotoSearchModal
|
<PhotoSearchModal
|
||||||
open={showPhotoSearch}
|
open={showPhotoSearch}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Hash, Loader2, RefreshCw, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HashtagSuggest — предлагает хештеги на основе текста поста.
|
||||||
|
* onAppend(text) — вставляет выбранные теги в конец поста
|
||||||
|
*/
|
||||||
|
export default function HashtagSuggest({ channelId, postText, onAppend }) {
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
if (!postText?.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setShown(true);
|
||||||
|
setSelected(new Set());
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/generate/hashtags', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channelId, postText, count: 10 }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
setTags(res.hashtags || []);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(tag) {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(tag) ? next.delete(tag) : next.add(tag);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSelected() {
|
||||||
|
if (!selected.size) return;
|
||||||
|
const line = '\n\n' + [...selected].map(t => `#${t}`).join(' ');
|
||||||
|
onAppend?.(line);
|
||||||
|
setSelected(new Set());
|
||||||
|
setShown(false);
|
||||||
|
setTags([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!postText?.trim()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
{!shown ? (
|
||||||
|
<button
|
||||||
|
onClick={fetchTags}
|
||||||
|
className="text-xs text-gray-500 hover:text-accent flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Hash className="w-3.5 h-3.5" />
|
||||||
|
Подобрать хештеги
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border bg-surface2 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<span className="text-xs font-medium text-gray-400 flex items-center gap-1">
|
||||||
|
<Hash className="w-3 h-3" /> Хештеги
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button onClick={fetchTags} disabled={loading} className="btn-ghost p-1" title="Обновить">
|
||||||
|
<RefreshCw className={`w-3 h-3 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShown(false); setTags([]); }} className="btn-ghost p-1">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 py-1">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Генерирую хештеги...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && tags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2.5">
|
||||||
|
{tags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggle(tag)}
|
||||||
|
className={`text-xs px-2 py-1 rounded-full border transition-colors ${
|
||||||
|
selected.has(tag)
|
||||||
|
? 'border-accent bg-accent/20 text-accent'
|
||||||
|
: 'border-border hover:border-accent/40 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={appendSelected}
|
||||||
|
className="btn-primary text-xs py-1.5 px-3 flex items-center gap-1.5 w-full justify-center"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Добавить {selected.size} {selected.size === 1 ? 'хештег' : 'хештега'} в пост
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && tags.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-500">Нет результатов. Попробуйте ещё раз.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Plus, Trash2, Loader2, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PollModal({ channel, onClose, onPublished }) {
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [options, setOptions] = useState(['', '']);
|
||||||
|
const [isAnonymous, setAnonymous] = useState(true);
|
||||||
|
const [isMultiple, setMultiple] = useState(false);
|
||||||
|
const [type, setType] = useState('regular');
|
||||||
|
const [correctId, setCorrectId] = useState(0);
|
||||||
|
const [explanation, setExplanation] = useState('');
|
||||||
|
const [scheduleAt, setScheduleAt] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function addOption() { if (options.length < 10) setOptions([...options, '']); }
|
||||||
|
function removeOption(i) { if (options.length > 2) setOptions(options.filter((_, idx) => idx !== i)); }
|
||||||
|
function setOption(i, val) { setOptions(options.map((o, idx) => idx === i ? val : o)); }
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!question.trim()) return setError('Введите вопрос');
|
||||||
|
if (options.some(o => !o.trim())) return setError('Заполните все варианты ответа');
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channel.id}/poll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
question: question.trim(),
|
||||||
|
options: options.map(o => o.trim()),
|
||||||
|
is_anonymous: isAnonymous,
|
||||||
|
allows_multiple_answers: isMultiple,
|
||||||
|
type,
|
||||||
|
correct_option_id: type === 'quiz' ? correctId : undefined,
|
||||||
|
explanation: type === 'quiz' ? explanation : undefined,
|
||||||
|
schedule_at: scheduleAt || null,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
onPublished?.(res);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-surface w-full max-w-lg rounded-2xl shadow-2xl border border-border flex flex-col max-h-[90vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<h2 className="font-semibold">Создать опрос</h2>
|
||||||
|
<button onClick={onClose} className="btn-ghost p-1.5"><X className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-4">
|
||||||
|
{/* Вопрос */}
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5">Вопрос <span className="text-gray-500 text-xs">({question.length}/300)</span></label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
maxLength={300}
|
||||||
|
value={question}
|
||||||
|
onChange={e => setQuestion(e.target.value)}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
placeholder="Что думаете о..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тип */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[['regular', '📊 Обычный'], ['quiz', '🎯 Викторина']].map(([v, l]) => (
|
||||||
|
<button key={v} type="button" onClick={() => setType(v)}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors border ${
|
||||||
|
type === v ? 'border-accent bg-accent/10 text-accent' : 'border-border hover:border-accent/40'
|
||||||
|
}`}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Варианты */}
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5">Варианты ответа</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{options.map((opt, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
{type === 'quiz' && (
|
||||||
|
<button type="button" onClick={() => setCorrectId(i)}
|
||||||
|
className={`w-5 h-5 rounded-full border-2 shrink-0 transition-colors ${
|
||||||
|
correctId === i ? 'border-green-500 bg-green-500' : 'border-gray-500'
|
||||||
|
}`} title="Правильный ответ" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
value={opt}
|
||||||
|
onChange={e => setOption(i, e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
className="input flex-1 py-1.5 text-sm"
|
||||||
|
placeholder={`Вариант ${i + 1}`}
|
||||||
|
/>
|
||||||
|
{options.length > 2 && (
|
||||||
|
<button onClick={() => removeOption(i)} className="btn-ghost p-1.5 text-gray-500">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{options.length < 10 && (
|
||||||
|
<button onClick={addOption} className="mt-2 text-sm text-accent hover:text-accent/80 flex items-center gap-1">
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Добавить вариант
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Объяснение для викторины */}
|
||||||
|
{type === 'quiz' && (
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5">Объяснение <span className="text-gray-500 text-xs">(показывается после ответа)</span></label>
|
||||||
|
<input
|
||||||
|
value={explanation}
|
||||||
|
onChange={e => setExplanation(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
placeholder="Правильный ответ, потому что..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Настройки */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<label className="label">Настройки</label>
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={isAnonymous} onChange={e => setAnonymous(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-accent" />
|
||||||
|
<span className="text-sm">Анонимное голосование</span>
|
||||||
|
</label>
|
||||||
|
{type === 'regular' && (
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={isMultiple} onChange={e => setMultiple(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-accent" />
|
||||||
|
<span className="text-sm">Несколько ответов</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Отложенная публикация */}
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5">Запланировать <span className="text-gray-500 text-xs">(оставьте пустым чтобы опубликовать сейчас)</span></label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduleAt}
|
||||||
|
onChange={e => setScheduleAt(e.target.value)}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 border-t border-border flex justify-end gap-2 shrink-0">
|
||||||
|
<button onClick={onClose} className="btn-ghost px-4">Отмена</button>
|
||||||
|
<button onClick={submit} disabled={loading} className="btn-primary px-5">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : scheduleAt ? '📅 Запланировать' : '📊 Опубликовать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user