Files
postcast-tool/components/PhotoSearchModal.js
T
Nik (Claude) 2e550d2993 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
2026-06-07 14:04:14 +03:00

288 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Search, X, AlertCircle, ExternalLink, Check } from 'lucide-react';
// Простая эвристика: если в тексте поста есть капитализованные ФИО (двусловные) —
// предлагаем их как стартовый query. Иначе берём первые слова темы.
function suggestQuery({ topic, post }) {
const text = `${topic || ''}\n${post || ''}`.trim();
if (!text) return '';
// Имена вида «Имя Фамилия» (две заглавные кириллицей или латиницей подряд)
const reName = /\b([А-ЯЁA-Z][а-яёa-z]{2,})\s+([А-ЯЁA-Z][а-яёa-z]{2,})/g;
const matches = [];
let m;
while ((m = reName.exec(text)) !== null) {
matches.push(`${m[1]} ${m[2]}`);
if (matches.length >= 3) break;
}
if (matches.length) return matches[0];
// Иначе — первые ~6 слов темы
return (topic || '').split(/\s+/).slice(0, 6).join(' ');
}
export default function PhotoSearchModal({ open, onClose, topic, post, onPick }) {
const [profiles, setProfiles] = useState([]);
const [profile, setProfile] = useState('general');
const [quota, setQuota] = useState(null);
const [query, setQuery] = useState('');
const [items, setItems] = useState([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [meta, setMeta] = useState(null);
const [pickedIdx, setPickedIdx] = useState(null);
// Загружаем профили + квоту при первом открытии
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const [profRes, quotaRes] = await Promise.all([
fetch('/api/photo-search/profiles'),
fetch('/api/photo-search/quota'),
]);
const profData = profRes.ok ? await profRes.json() : [];
const quotaData = quotaRes.ok ? await quotaRes.json() : null;
if (cancelled) return;
setProfiles(profData);
setQuota(quotaData);
// Дефолт-профиль: general (если есть), иначе первый
if (profData.length && !profData.find(p => p.slug === profile)) {
setProfile(profData[0].slug);
}
} catch (e) {
if (!cancelled) setError(e.message);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Подсказываем стартовый запрос
useEffect(() => {
if (!open) return;
if (!query) setQuery(suggestQuery({ topic, post }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function search() {
if (!query.trim() || searching) return;
setSearching(true);
setError('');
setItems([]);
setPickedIdx(null);
try {
const res = await fetch('/api/photo-search/by-query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.trim(), profile, num: 6 }),
});
const data = await res.json();
if (!res.ok) {
if (data.code === 'DAILY_LIMIT_EXCEEDED') {
throw new Error('Дневной лимит поиска фото исчерпан. Попробуй завтра или подними лимит в /system.');
}
throw new Error(data.error || `HTTP ${res.status}`);
}
setItems(data.items || []);
setMeta({
total: data.total,
raw: data.raw_count,
filtered: data.filtered_count,
elapsedMs: data.elapsed_ms,
domains: data.domains || [],
});
if (data.quota) setQuota(data.quota);
if (!data.items || data.items.length === 0) {
setError('Ничего не нашлось в whitelisted доменах. Попробуй другой профиль или уточни запрос.');
}
} catch (e) {
setError(e.message);
} finally {
setSearching(false);
}
}
function pick(idx) {
const item = items[idx];
if (!item) return;
setPickedIdx(idx);
onPick?.({
imageUrl: item.imageUrl,
thumbUrl: item.thumbUrl,
credit: {
domain: item.sourceDomain || null,
sourceUrl: item.sourceUrl || null,
title: item.title || null,
},
});
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start sm:items-center justify-center p-3 sm:p-6 overflow-y-auto"
onClick={onClose}
>
<div
className="card w-full max-w-3xl my-4"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<Search className="w-4 h-4 text-accent" />
Найти фото
</h3>
<p className="text-xs text-gray-500 mt-1">
Поиск по доменам из whitelist'а профиля. Используется Yandex Search API.
</p>
</div>
<button onClick={onClose} className="btn-ghost p-2" title="Закрыть">
<X className="w-4 h-4" />
</button>
</div>
{/* Controls */}
<div className="p-5 space-y-3 border-b border-border">
<div className="grid sm:grid-cols-[1fr_auto] gap-2">
<div>
<label className="label text-xs">Запрос</label>
<input
className="input text-sm"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') search(); }}
placeholder="Имя, событие, объект…"
autoFocus
/>
</div>
<div>
<label className="label text-xs">Профиль</label>
<select
className="input text-sm"
value={profile}
onChange={e => setProfile(e.target.value)}
>
{profiles.length === 0 && <option value="general">general</option>}
{profiles.map(p => (
<option key={p.slug} value={p.slug}>
{p.name || p.slug} {p.domains?.length ? `(${p.domains.length})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-xs text-gray-500">
{quota && (
<>Квота сегодня: <b>{quota.used}</b> / {quota.limit}{' '}
{quota.remaining === 0 && <span className="text-amber-500">(исчерпана)</span>}
</>
)}
</div>
<button
onClick={search}
disabled={searching || !query.trim() || (quota?.remaining === 0)}
className="btn-primary text-sm"
>
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{searching ? 'Ищу' : 'Найти'}
</button>
</div>
{profiles.find(p => p.slug === profile)?.domains?.length > 0 && (
<div className="text-[11px] text-gray-500">
Whitelist:{' '}
{profiles.find(p => p.slug === profile).domains.slice(0, 8).join(', ')}
{profiles.find(p => p.slug === profile).domains.length > 8 && ''}
</div>
)}
</div>
{/* Body */}
<div className="p-5">
{error && (
<div className="flex items-start gap-2 text-sm text-amber-500 bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 mb-3">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div>{error}</div>
</div>
)}
{meta && !error && (
<div className="text-[11px] text-gray-500 mb-3">
Найдено всего: {meta.total} · после фильтра: {meta.filtered} · показано: {items.length} · {meta.elapsedMs} мс
</div>
)}
{items.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{items.map((it, i) => (
<button
key={i}
onClick={() => pick(i)}
className={`group relative rounded-lg overflow-hidden border transition-all text-left ${
pickedIdx === i
? 'border-accent ring-2 ring-accent/40'
: 'border-border hover:border-accent/60'
}`}
title={it.title || it.sourceUrl}
>
<div className="aspect-square bg-surface2 overflow-hidden">
<img
src={it.thumbUrl || it.imageUrl}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { e.currentTarget.style.opacity = '0.3'; }}
/>
</div>
<div className="p-2">
<div className="text-[10px] text-accent font-mono truncate">{it.sourceDomain}</div>
{it.title && (
<div className="text-[11px] text-gray-500 line-clamp-2 mt-0.5">{it.title}</div>
)}
<div className="text-[10px] text-gray-500 mt-1">{it.width}×{it.height}</div>
</div>
{pickedIdx === i && (
<div className="absolute top-1.5 right-1.5 bg-accent text-white rounded-full p-1">
<Check className="w-3 h-3" />
</div>
)}
{it.sourceUrl && (
<a
href={it.sourceUrl}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="absolute top-1.5 left-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full p-1"
title="Открыть источник"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</button>
))}
</div>
)}
{items.length === 0 && !error && !searching && (
<div className="text-sm text-gray-500 text-center py-10">
Введи запрос и нажми «Найти»
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex items-center justify-end gap-2">
<button onClick={onClose} className="btn-ghost text-sm">Закрыть</button>
</div>
</div>
</div>
);
}