feat: PollModal + HashtagSuggest UI

PollModal:
- Вопрос + 2-10 вариантов ответа
- Типы: обычный / викторина (с правильным ответом и объяснением)
- Настройки: анонимность, несколько ответов
- Отложенная публикация через datetime-local
- Кнопка «📊 Опрос» в ChannelView (только для TG каналов)

HashtagSuggest:
- Появляется под сгенерированным постом
- Запрос в /api/generate/hashtags → Claude Haiku
- Клик по тегу — выбор, кнопка «Добавить в пост»
- Обновление тегов, закрытие панели
This commit is contained in:
Ник (Claude)
2026-06-11 19:55:24 +03:00
parent 675d04c5ab
commit d0fd328011
5 changed files with 376 additions and 1 deletions
+175
View File
@@ -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>
);
}