feat: photo-search, system settings, ROADMAP

- PhotoSearchModal: Yandex photo-search с профилями доменов
- SystemSettings: управление app_settings (admin-only, /system)
- ROADMAP.md: актуальный план фич P1-P10
- Header, ChannelView, session: поддержка is_admin
This commit is contained in:
Nik (Claude)
2026-06-07 14:04:14 +03:00
parent 76eb519018
commit 2e550d2993
14 changed files with 931 additions and 38 deletions
+142 -32
View File
@@ -4,8 +4,9 @@ import Link from 'next/link';
import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2
MessageSquare, Pencil, X, Send, Clock, Search, Camera, ExternalLink
} from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal';
const GOAL_LABELS = {
educational: 'Обучение', news: 'Новости',
@@ -22,6 +23,15 @@ const TRANSFORMS = [
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
];
// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать
function buildCaption(domain) {
return domain ? `\n\n📷 Фото: ${domain}` : '';
}
// Удаляет существующую подпись из текста поста (по любому домену)
function stripCaption(text) {
return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd();
}
export default function ChannelView({ channel }) {
const [topic, setTopic] = useState('');
const [generating, setGenerating] = useState(false);
@@ -36,8 +46,12 @@ export default function ChannelView({ channel }) {
// Картинка
const [image, setImage] = useState(null);
const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
const [genImage, setGenImage] = useState(false);
// Photo search modal
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
// Трансформации
const [transforming, setTransforming] = useState(false);
@@ -74,7 +88,6 @@ export default function ChannelView({ channel }) {
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
// Подгрузка истории при монтировании
useEffect(() => { loadHistory(); }, []);
async function loadHistory() {
@@ -86,6 +99,23 @@ export default function ChannelView({ channel }) {
} catch {} finally { setLoadingHistory(false); }
}
function clearImage() {
setImage(null);
setImageCredit(null);
// Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото
if (post) setPost(p => stripCaption(p));
}
function applyPhotoPick({ imageUrl, credit }) {
setImage(imageUrl);
setImageCredit(credit || null);
// Подменяем (или добавляем) caption
if (post && credit?.domain) {
setPost(p => stripCaption(p) + buildCaption(credit.domain));
}
setShowPhotoSearch(false);
}
async function savePost(status = 'draft', scheduledAt = null) {
if (!post) return;
setPublishing(true);
@@ -93,12 +123,12 @@ export default function ChannelView({ channel }) {
try {
let id = savedPostId;
if (!id) {
// Создаём
const res = await fetch('/api/user-posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: channel.id, content: post, image_url: image,
channel_id: channel.id, content: post,
image_url: image, image_credit: imageCredit,
topic: topic.trim(), status, scheduled_at: scheduledAt,
}),
});
@@ -107,11 +137,15 @@ export default function ChannelView({ channel }) {
id = data.id;
setSavedPostId(id);
} else {
// Обновляем
const res = await fetch(`/api/user-posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }),
body: JSON.stringify({
content: post,
image_url: image,
image_credit: imageCredit,
status, scheduled_at: scheduledAt,
}),
});
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
}
@@ -137,6 +171,7 @@ export default function ChannelView({ channel }) {
setPost(null);
setSavedPostId(null);
setImage(null);
setImageCredit(null);
setTopic('');
} catch (err) { setError(err.message); }
finally { setPublishing(false); }
@@ -151,6 +186,7 @@ export default function ChannelView({ channel }) {
setPost(null);
setSavedPostId(null);
setImage(null);
setImageCredit(null);
setTopic('');
}
@@ -183,14 +219,14 @@ export default function ChannelView({ channel }) {
if (!final) throw new Error('Таймаут — попробуй ещё раз');
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
// Сохраняем предыдущий вариант в variants
if (asVariant && post) {
setVariants(v => [...v, { content: post, tokens, image }]);
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
}
setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out });
setImage(null); // сбрасываем картинку при новом посте
setImage(null);
setImageCredit(null);
} catch (err) {
setError(err.message);
} finally {
@@ -210,10 +246,10 @@ export default function ChannelView({ channel }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
// Сохраняем текущий в варианты
setVariants(v => [...v, { content: post, tokens, image }]);
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
setPost(data.content);
setImage(null);
setImageCredit(null);
} catch (err) {
setError(err.message);
} finally {
@@ -234,6 +270,7 @@ export default function ChannelView({ channel }) {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
setImage(data.url);
setImageCredit(null); // сгенерированная — без credit'а
} catch (err) {
setError(err.message);
} finally {
@@ -245,12 +282,13 @@ export default function ChannelView({ channel }) {
const v = variants[idx];
setVariants(arr => {
const next = arr.filter((_, i) => i !== idx);
next.push({ content: post, tokens, image });
next.push({ content: post, tokens, image, imageCredit });
return next;
});
setPost(v.content);
setTokens(v.tokens);
setImage(v.image);
setImageCredit(v.imageCredit || null);
}
async function copy() {
@@ -299,7 +337,6 @@ export default function ChannelView({ channel }) {
</button>
</div>
{/* Список идей */}
{showIdeas && ideas.length > 0 && (
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center justify-between mb-2">
@@ -384,7 +421,6 @@ export default function ChannelView({ channel }) {
</div>
</div>
{/* Сам пост — редактируемый или нет */}
{editing ? (
<textarea
value={post}
@@ -400,30 +436,86 @@ export default function ChannelView({ channel }) {
{/* Картинка к посту */}
{image && (
<div className="mt-4 relative">
<img src={image} alt="" className="w-full rounded-lg" />
<div className="mt-4">
<div className="relative">
<img src={image} alt="" className="w-full rounded-lg" referrerPolicy="no-referrer" />
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
title="Убрать картинку"
>
<X className="w-4 h-4" />
</button>
</div>
{imageCredit?.domain && (
<div className="mt-2 flex items-center justify-between flex-wrap gap-2 text-xs">
<div className="text-gray-500">
📷 Фото:{' '}
{imageCredit.sourceUrl ? (
<a
href={imageCredit.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
{imageCredit.domain}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-accent">{imageCredit.domain}</span>
)}
</div>
<button
onClick={() => setPost(p => stripCaption(p))}
className="text-gray-500 hover:text-gray-300"
title="Убрать подпись «📷 Фото: …» из текста поста (саму картинку оставить)"
>
убрать подпись из поста
</button>
</div>
)}
</div>
)}
{/* Кнопки получения картинки */}
{!image && (
<div className="mt-4 grid sm:grid-cols-2 gap-2">
<button
onClick={() => setImage(null)}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
onClick={() => setShowPhotoSearch(true)}
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors"
>
<X className="w-4 h-4" />
<Search className="w-4 h-4" /> Найти фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
>
{genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую (~30 сек)</>
) : (
<><Camera className="w-4 h-4" /> Сгенерировать (AI)</>
)}
</button>
</div>
)}
{/* Кнопка генерации картинки */}
{!image && (
<div className="mt-4">
{/* Если уже есть картинка — даём ещё раз поменять */}
{image && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<button
onClick={() => setShowPhotoSearch(true)}
className="btn-ghost text-xs py-1"
>
<Search className="w-3.5 h-3.5" /> Другое фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="w-full inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
className="btn-ghost text-xs py-1"
>
{genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
) : (
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
)}
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
Заменить AI-картинкой
</button>
</div>
)}
@@ -457,7 +549,6 @@ export default function ChannelView({ channel }) {
</button>
</div>
{/* Планировщик */}
{showScheduler && (
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
<label className="label text-xs">Время публикации (МСК)</label>
@@ -498,6 +589,15 @@ export default function ChannelView({ channel }) {
</div>
)}
{/* Photo search modal */}
<PhotoSearchModal
open={showPhotoSearch}
onClose={() => setShowPhotoSearch(false)}
topic={topic}
post={post}
onPick={applyPhotoPick}
/>
{/* История вариантов */}
{variants.length > 0 && (
<div className="card p-5">
@@ -541,11 +641,11 @@ export default function ChannelView({ channel }) {
return (
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
{p.image_url && (
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" referrerPolicy="no-referrer" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs flex-wrap">
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
{statusLabels[p.status] || p.status}
</span>
@@ -558,13 +658,23 @@ export default function ChannelView({ channel }) {
{!p.scheduled_at && !p.published_at && (
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.image_credit?.domain && (
<span className="text-gray-500">📷 {p.image_credit.domain}</span>
)}
{p.error && (
<span className="text-red-400 truncate">{p.error}</span>
)}
</div>
</div>
<button
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
onClick={() => {
setPost(p.content);
setImage(p.image_url);
setImageCredit(p.image_credit || null);
setSavedPostId(p.id);
setTopic(p.topic || '');
window.scrollTo({top: 0, behavior: 'smooth'});
}}
className="text-xs text-accent hover:underline shrink-0"
>
открыть