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:
+158
@@ -0,0 +1,158 @@
|
|||||||
|
# ZeroPost — Roadmap (план фич)
|
||||||
|
|
||||||
|
Живой документ. Обновлять по мере выполнения.
|
||||||
|
Последнее обновление: 2026-06-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст / архитектура
|
||||||
|
|
||||||
|
- **zeropost.ru** (web) — блог + админка. PM2 `zeropost-web` (pm_id=5, порт 3042), `/var/www/zeropost-web`.
|
||||||
|
- **app.zeropost.ru** (tool) — SaaS для управления каналами TG/VK/MAX. PM2 `zeropost-tool` (pm_id=4, порт 3041), `/var/www/zeropost-tool`.
|
||||||
|
- **engine** — общий движок API + LLM-pipeline. PM2 `zeropost-engine` (pm_id=3, порт 3030), `/var/www/zeropost-engine`.
|
||||||
|
- БД: PostgreSQL, db=`zeropost`.
|
||||||
|
- Cron: `*/10 * * * * zeropost-autogen.sh`, `* * * * * zeropost-publish-scheduled.sh` (обе очереди).
|
||||||
|
- Internal secret: `zeropost_internal_2026`. TG-прокси: CF Worker в `app_settings.TELEGRAM_API_BASE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ DONE
|
||||||
|
|
||||||
|
### Photo-search (Yandex)
|
||||||
|
- `user_posts.image_credit` (JSONB), `users.is_admin` (BOOLEAN).
|
||||||
|
- Engine: proxy `/api/photo-search/*`. Tool: `/system` (admin-only), `PhotoSearchModal`.
|
||||||
|
- Web: категория `photo_search` скрыта из blog-админки.
|
||||||
|
|
||||||
|
### Auto-publish статей блога → каналы
|
||||||
|
- `channels.auto_publish_{enabled,categories,delay_min,template,with_cover,button_text,image_source}`.
|
||||||
|
- Engine: `articleAutoPublish.js`, `scheduledPostsRunner.js`, `/api/scheduled-posts/*`.
|
||||||
|
- Хук в `PATCH/POST articles` при `draft→published`. Typeahead `/api/articles/admin/search`.
|
||||||
|
- Cron обновлён — дёргает обе очереди (`user_posts` + `scheduled_posts`).
|
||||||
|
- Web: `ArticlePicker`, `AutoPublishTab`, 4 вкладки в `ChannelEditor`.
|
||||||
|
|
||||||
|
### Журнальная главная страница zeropost.ru
|
||||||
|
- Engine: `/api/articles/home` (hero / byCategory / popular / recent).
|
||||||
|
- Web: `CategoryRow`, `PopularBlock`, `RecentBlock` (группировка Сегодня/Вчера/Эта неделя).
|
||||||
|
- `ArticleCard` с 3 размерами (hero/regular/compact) + цветной category badge без дублей.
|
||||||
|
- Header упрощён (2 пункта desktop + расширенное мобильное меню).
|
||||||
|
|
||||||
|
### Персонаж Зеро
|
||||||
|
- 15 поз: `avatar, coding, tools, lock, gears, eureka, confused, facepalm, victory, tired, reading, magnifier, chart, meditate, present` → `/var/www/zeropost-uploads/zero-{name}.webp`.
|
||||||
|
- `src/services/zeroCharacter.js` — выбор позы по тексту/категории статьи (эмоциональные триггеры + категорийные).
|
||||||
|
- `channels.auto_publish_image_source = 'alternating'` — чётные посты = AI-обложка, нечётные = Зеро.
|
||||||
|
- `scheduledPostsRunner` — multipart upload (не URL) чтобы CF Worker не падал.
|
||||||
|
|
||||||
|
### Промпт Зеро
|
||||||
|
- `src/services/promptBuilder.js` — секция `author_persona` в `buildArticleSystemPrompt`.
|
||||||
|
- `src/services/articles.js` — `blogChannel.author_persona` (голос, правила, запрещённые фразы).
|
||||||
|
- Статьи теперь пишутся от первого лица с личными историями.
|
||||||
|
|
||||||
|
### TG-канал @zeropostru — запуск
|
||||||
|
- Welcome-пост от Зеро с аватаром + кнопкой (msg_id=13, закреплён).
|
||||||
|
- 4 статьи опубликованы (по одной на каждую категорию), режим alternating.
|
||||||
|
|
||||||
|
### zeropost.ru — страница Зеро + TG-продвижение
|
||||||
|
- `/about/zero` — страница с описанием персонажа + галерея 8 поз.
|
||||||
|
- Footer — TG-банер с аватаром Зеро на каждой странице.
|
||||||
|
- Конец каждой статьи — блок «Понравилась заметка? → В канал».
|
||||||
|
- `/about` — ссылка «Познакомьтесь с Зеро».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 ПЛАН: что делать дальше
|
||||||
|
|
||||||
|
### СЕЙЧАС (не отложить)
|
||||||
|
|
||||||
|
**A. Revoke бота** — токен @zeropostru_bot засветился в этом чате. Зайди в @BotFather → /mybots → выбери бота → API Token → Revoke. Потом обнови в `/admin/channels/1` → Настройки → Bot Token.
|
||||||
|
|
||||||
|
**B. Статья на Habr** — главный бесплатный способ получить первые 200-500 живых читателей. Тема: «Я сделал блог, который ведёт ИИ с персонажем-маскотом. Как работает pipeline». Напишу черновик — дай команду.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### app.zeropost.ru — приоритетный порядок
|
||||||
|
|
||||||
|
#### P1. Календарь публикаций (1–2 дня)
|
||||||
|
Самый частый запрос у SMM-инструментов. Без него непонятно «что когда выходит».
|
||||||
|
- Страница `/calendar` — визуальная сетка (неделя + месяц).
|
||||||
|
- Данные: `user_posts.scheduled_at` + `publish_slots` канала.
|
||||||
|
- Карточки по цветам: draft=серый, scheduled=синий, published=зелёный, failed=красный.
|
||||||
|
- Drag & drop между датами → PATCH `scheduled_at`.
|
||||||
|
- Фильтр по каналу.
|
||||||
|
|
||||||
|
#### P2. Превью под платформу (0.5–1 день)
|
||||||
|
Сейчас пишешь пост и не знаешь как он будет выглядеть.
|
||||||
|
- Компонент `PostPreview` в `ChannelView` справа от textarea.
|
||||||
|
- Рендерит Markdown как TG: **жирный**, _курсив_, обрезка caption 1024, превью ссылки.
|
||||||
|
- Переключатель TG / VK / MAX — разные ограничения форматирования.
|
||||||
|
|
||||||
|
#### P3. Шаблоны постов (0.5 дня)
|
||||||
|
Ускоряет создание поста в 3 раза.
|
||||||
|
- 7 кнопок-пресетов: Новость, Анонс, Кейс, Лонгрид, Подборка, Опрос-разбор, Личное мнение.
|
||||||
|
- Каждый — готовая структура поста + hint для AI.
|
||||||
|
- В `ChannelView` рядом с кнопкой «Идеи тем».
|
||||||
|
|
||||||
|
#### P4. Аналитика постов (2–3 дня)
|
||||||
|
Без метрик невозможно понять что заходит.
|
||||||
|
- Таблица `post_metrics(user_post_id, captured_at, views, forwards, reactions JSONB)`.
|
||||||
|
- Воркер раз в 15 мин: пуллит views через TG embed для постов < 7 дней.
|
||||||
|
- В `ChannelView` у каждого поста — строчка «👁 N ↗ N ❤️ N».
|
||||||
|
- График: «лучший день/час для публикации» по медиане views.
|
||||||
|
|
||||||
|
#### P5. URL → черновик (1–2 дня)
|
||||||
|
Killer feature которой нет у конкурентов в таком качестве.
|
||||||
|
- Вставил ссылку → AI читает статью/YouTube/TG-пост → пишет пост в стиле канала.
|
||||||
|
- `POST /api/generate/from-url`. Для статей: cheerio + og-meta. Для YouTube: yt-dlp транскрипт.
|
||||||
|
|
||||||
|
#### P6. Комментарии + AI-ответы (4–7 дней)
|
||||||
|
Большая фича, отдельный спринт.
|
||||||
|
- TG webhook → unified inbox.
|
||||||
|
- Классификатор haiku: вопрос/спам/похвала/троллинг.
|
||||||
|
- Предложенный AI-ответ с кнопкой «отправить».
|
||||||
|
|
||||||
|
#### P7–P10. Опросы, хештеги, best-time, URL-shortener
|
||||||
|
По 0.5–1 дню каждое, делаем после P1–P5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### zeropost.ru — мелкий должок
|
||||||
|
|
||||||
|
| Задача | Срочность |
|
||||||
|
|---|---|
|
||||||
|
| Revoke бота @zeropostru_bot | 🔴 СЕЙЧАС |
|
||||||
|
| Статья на Habr про ZeroPost | 🟠 На этой неделе |
|
||||||
|
| Кнопка «Бэкфилл статей» в AutoPublishTab | 🟡 Низкая |
|
||||||
|
| История публикаций канала | 🟡 Низкая |
|
||||||
|
| Балансы внешних сервисов в /system | 🟡 Низкая |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### zeropost.ru — рост аудитории (без бюджета)
|
||||||
|
|
||||||
|
| Действие | Ожидаемый результат | Когда |
|
||||||
|
|---|---|---|
|
||||||
|
| Habr: «Как я сделал AI-блог с маскотом» | 200–500 переходов | На этой неделе |
|
||||||
|
| vc.ru: то же самое | 100–300 переходов | На этой неделе |
|
||||||
|
| 5 комментариев в AI-чатах TG | 20–50 подписчиков | Постоянно |
|
||||||
|
| Взаимный пиар с 3 каналами (tgstat.ru) | 50–150 подписчиков | 1–2 недели |
|
||||||
|
| Reddit r/artificial | 50–200 переходов | На этой неделе |
|
||||||
|
| SEO (органика) | Долгосрочно | Само, 2–3 мес |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технический долг
|
||||||
|
|
||||||
|
- ENGINE_URL default в `lib/engine.js` tool'а = 3040, должен быть 3030.
|
||||||
|
- `per_day` в `autogen_settings` не используется в логике (только `run_hour:run_minute`).
|
||||||
|
- TOPIC_BANK заканчивается — нужен AI-генератор новых тем.
|
||||||
|
- VK: публикация без фото (нужен 2-step `photos.getWallUploadServer`).
|
||||||
|
- MAX: заглушка `throw new Error('не реализована')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Не делаем
|
||||||
|
|
||||||
|
- Холст / визуальный редактор.
|
||||||
|
- Stories / Reels.
|
||||||
|
- Команда / роли (до 5+ клиентов).
|
||||||
|
- White Label.
|
||||||
|
- Парсинг конкурентов через MTProto.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function PUT(req, { params }) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
try {
|
||||||
|
const { key } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const row = await engine.updateSetting(key, body?.value);
|
||||||
|
return NextResponse.json(row);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
// GET /api/admin/settings?category=photo_search
|
||||||
|
export async function GET(req) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const category = searchParams.get('category') || undefined;
|
||||||
|
const rows = await engine.listSettings(category);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,19 +16,23 @@ export async function POST(req) {
|
|||||||
}
|
}
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const { rows } = await q(
|
const { rows } = await q(
|
||||||
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
|
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`,
|
||||||
[email, hash]
|
[email, hash]
|
||||||
);
|
);
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
const s = await getSession();
|
const s = await getSession();
|
||||||
s.userId = user.id;
|
s.userId = user.id;
|
||||||
s.email = user.email;
|
s.email = user.email;
|
||||||
|
s.isAdmin = !!user.is_admin;
|
||||||
await s.save();
|
await s.save();
|
||||||
return NextResponse.json({ ok: true, user });
|
return NextResponse.json({ ok: true, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
// login
|
// login
|
||||||
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
|
const { rows } = await q(
|
||||||
|
`SELECT id,email,password,name,is_admin FROM users WHERE email=$1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,10 @@ export async function POST(req) {
|
|||||||
s.userId = user.id;
|
s.userId = user.id;
|
||||||
s.email = user.email;
|
s.email = user.email;
|
||||||
s.name = user.name;
|
s.name = user.name;
|
||||||
|
s.isAdmin = !!user.is_admin;
|
||||||
await s.save();
|
await s.save();
|
||||||
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await engine.photoSearchByQuery(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message, code: err.code },
|
||||||
|
{ status: err.status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const data = await engine.photoSearchProfiles();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const data = await engine.photoSearchQuota();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import SystemSettings from '@/components/SystemSettings';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function SystemPage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
if (!user.isAdmin) redirect('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header user={user} />
|
||||||
|
<main className="max-w-4xl mx-auto p-4 sm:p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Системные настройки</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SystemSettings />
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+142
-32
@@ -4,8 +4,9 @@ import Link from 'next/link';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
|
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
|
||||||
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
|
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';
|
} from 'lucide-react';
|
||||||
|
import PhotoSearchModal from './PhotoSearchModal';
|
||||||
|
|
||||||
const GOAL_LABELS = {
|
const GOAL_LABELS = {
|
||||||
educational: 'Обучение', news: 'Новости',
|
educational: 'Обучение', news: 'Новости',
|
||||||
@@ -22,6 +23,15 @@ const TRANSFORMS = [
|
|||||||
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
|
{ 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 }) {
|
export default function ChannelView({ channel }) {
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
@@ -36,8 +46,12 @@ export default function ChannelView({ channel }) {
|
|||||||
|
|
||||||
// Картинка
|
// Картинка
|
||||||
const [image, setImage] = useState(null);
|
const [image, setImage] = useState(null);
|
||||||
|
const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
|
||||||
const [genImage, setGenImage] = useState(false);
|
const [genImage, setGenImage] = useState(false);
|
||||||
|
|
||||||
|
// Photo search modal
|
||||||
|
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
|
||||||
|
|
||||||
// Трансформации
|
// Трансформации
|
||||||
const [transforming, setTransforming] = useState(false);
|
const [transforming, setTransforming] = useState(false);
|
||||||
|
|
||||||
@@ -74,7 +88,6 @@ export default function ChannelView({ channel }) {
|
|||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
// Подгрузка истории при монтировании
|
|
||||||
useEffect(() => { loadHistory(); }, []);
|
useEffect(() => { loadHistory(); }, []);
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
@@ -86,6 +99,23 @@ export default function ChannelView({ channel }) {
|
|||||||
} catch {} finally { setLoadingHistory(false); }
|
} 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) {
|
async function savePost(status = 'draft', scheduledAt = null) {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
@@ -93,12 +123,12 @@ export default function ChannelView({ channel }) {
|
|||||||
try {
|
try {
|
||||||
let id = savedPostId;
|
let id = savedPostId;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
// Создаём
|
|
||||||
const res = await fetch('/api/user-posts', {
|
const res = await fetch('/api/user-posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
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,
|
topic: topic.trim(), status, scheduled_at: scheduledAt,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -107,11 +137,15 @@ export default function ChannelView({ channel }) {
|
|||||||
id = data.id;
|
id = data.id;
|
||||||
setSavedPostId(id);
|
setSavedPostId(id);
|
||||||
} else {
|
} else {
|
||||||
// Обновляем
|
|
||||||
const res = await fetch(`/api/user-posts/${id}`, {
|
const res = await fetch(`/api/user-posts/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 || 'Ошибка');
|
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
|
||||||
}
|
}
|
||||||
@@ -137,6 +171,7 @@ export default function ChannelView({ channel }) {
|
|||||||
setPost(null);
|
setPost(null);
|
||||||
setSavedPostId(null);
|
setSavedPostId(null);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
setTopic('');
|
setTopic('');
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
finally { setPublishing(false); }
|
finally { setPublishing(false); }
|
||||||
@@ -151,6 +186,7 @@ export default function ChannelView({ channel }) {
|
|||||||
setPost(null);
|
setPost(null);
|
||||||
setSavedPostId(null);
|
setSavedPostId(null);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
setTopic('');
|
setTopic('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,14 +219,14 @@ export default function ChannelView({ channel }) {
|
|||||||
if (!final) throw new Error('Таймаут — попробуй ещё раз');
|
if (!final) throw new Error('Таймаут — попробуй ещё раз');
|
||||||
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
|
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
|
||||||
|
|
||||||
// Сохраняем предыдущий вариант в variants
|
|
||||||
if (asVariant && post) {
|
if (asVariant && post) {
|
||||||
setVariants(v => [...v, { content: post, tokens, image }]);
|
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPost(final.result);
|
setPost(final.result);
|
||||||
setTokens({ in: final.tokens_in, out: final.tokens_out });
|
setTokens({ in: final.tokens_in, out: final.tokens_out });
|
||||||
setImage(null); // сбрасываем картинку при новом посте
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -210,10 +246,10 @@ export default function ChannelView({ channel }) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Ошибка');
|
if (!res.ok) throw new Error(data.error || 'Ошибка');
|
||||||
// Сохраняем текущий в варианты
|
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||||
setVariants(v => [...v, { content: post, tokens, image }]);
|
|
||||||
setPost(data.content);
|
setPost(data.content);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
setImageCredit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,6 +270,7 @@ export default function ChannelView({ channel }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
|
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
|
||||||
setImage(data.url);
|
setImage(data.url);
|
||||||
|
setImageCredit(null); // сгенерированная — без credit'а
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,12 +282,13 @@ export default function ChannelView({ channel }) {
|
|||||||
const v = variants[idx];
|
const v = variants[idx];
|
||||||
setVariants(arr => {
|
setVariants(arr => {
|
||||||
const next = arr.filter((_, i) => i !== idx);
|
const next = arr.filter((_, i) => i !== idx);
|
||||||
next.push({ content: post, tokens, image });
|
next.push({ content: post, tokens, image, imageCredit });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setPost(v.content);
|
setPost(v.content);
|
||||||
setTokens(v.tokens);
|
setTokens(v.tokens);
|
||||||
setImage(v.image);
|
setImage(v.image);
|
||||||
|
setImageCredit(v.imageCredit || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
@@ -299,7 +337,6 @@ export default function ChannelView({ channel }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Список идей */}
|
|
||||||
{showIdeas && ideas.length > 0 && (
|
{showIdeas && ideas.length > 0 && (
|
||||||
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
|
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -384,7 +421,6 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Сам пост — редактируемый или нет */}
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={post}
|
value={post}
|
||||||
@@ -400,30 +436,86 @@ export default function ChannelView({ channel }) {
|
|||||||
|
|
||||||
{/* Картинка к посту */}
|
{/* Картинка к посту */}
|
||||||
{image && (
|
{image && (
|
||||||
<div className="mt-4 relative">
|
<div className="mt-4">
|
||||||
<img src={image} alt="" className="w-full rounded-lg" />
|
<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
|
<button
|
||||||
onClick={() => setImage(null)}
|
onClick={() => setShowPhotoSearch(true)}
|
||||||
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Кнопка генерации картинки */}
|
{/* Если уже есть картинка — даём ещё раз поменять */}
|
||||||
{!image && (
|
{image && (
|
||||||
<div className="mt-4">
|
<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
|
<button
|
||||||
onClick={generateImage}
|
onClick={generateImage}
|
||||||
disabled={genImage}
|
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 ? (
|
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
|
||||||
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
|
Заменить AI-картинкой
|
||||||
) : (
|
|
||||||
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -457,7 +549,6 @@ export default function ChannelView({ channel }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Планировщик */}
|
|
||||||
{showScheduler && (
|
{showScheduler && (
|
||||||
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
|
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
|
||||||
<label className="label text-xs">Время публикации (МСК)</label>
|
<label className="label text-xs">Время публикации (МСК)</label>
|
||||||
@@ -498,6 +589,15 @@ export default function ChannelView({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Photo search modal */}
|
||||||
|
<PhotoSearchModal
|
||||||
|
open={showPhotoSearch}
|
||||||
|
onClose={() => setShowPhotoSearch(false)}
|
||||||
|
topic={topic}
|
||||||
|
post={post}
|
||||||
|
onPick={applyPhotoPick}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* История вариантов */}
|
{/* История вариантов */}
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
@@ -541,11 +641,11 @@ export default function ChannelView({ channel }) {
|
|||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
|
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
|
||||||
{p.image_url && (
|
{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="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="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}`}>
|
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
|
||||||
{statusLabels[p.status] || p.status}
|
{statusLabels[p.status] || p.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -558,13 +658,23 @@ export default function ChannelView({ channel }) {
|
|||||||
{!p.scheduled_at && !p.published_at && (
|
{!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>
|
<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 && (
|
{p.error && (
|
||||||
<span className="text-red-400 truncate">{p.error}</span>
|
<span className="text-red-400 truncate">{p.error}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
className="text-xs text-accent hover:underline shrink-0"
|
||||||
>
|
>
|
||||||
↑ открыть
|
↑ открыть
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Sparkles, LogOut } from 'lucide-react';
|
import { Sparkles, LogOut, Settings2 } from 'lucide-react';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
|
||||||
export default function Header({ user }) {
|
export default function Header({ user }) {
|
||||||
@@ -18,6 +18,16 @@ export default function Header({ user }) {
|
|||||||
<span className="font-bold">ZeroPost</span>
|
<span className="font-bold">ZeroPost</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/system"
|
||||||
|
className="btn-ghost p-2 text-sm hidden sm:inline-flex"
|
||||||
|
title="Системные настройки"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
<span className="hidden md:inline">Система</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span>
|
<span className="text-sm text-gray-500 hidden sm:inline mr-2">{user?.email}</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
|
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
// Категории, которые управляются здесь (в админке tool, а не в админке блога).
|
||||||
|
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ slug: 'photo_search', title: 'Поиск фото',
|
||||||
|
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
|
||||||
|
// Сюда позже: { slug: 'billing', ... }, { slug: 'serpapi', ... }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SystemSettings() {
|
||||||
|
const [byCategory, setByCategory] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = {};
|
||||||
|
for (const cat of CATEGORIES) {
|
||||||
|
const res = await fetch(`/api/admin/settings?category=${cat.slug}`);
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
|
||||||
|
result[cat.slug] = await res.json();
|
||||||
|
}
|
||||||
|
setByCategory(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="card p-12 text-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card p-5 border-red-500/40">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button onClick={load} className="btn-ghost mt-3 text-sm">
|
||||||
|
<RefreshCw className="w-4 h-4" /> Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<CategoryBlock
|
||||||
|
key={cat.slug}
|
||||||
|
category={cat}
|
||||||
|
rows={byCategory[cat.slug] || []}
|
||||||
|
onSaved={() => load()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBlock({ category, rows, onSaved }) {
|
||||||
|
return (
|
||||||
|
<section className="card p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="font-semibold">{category.title}</h2>
|
||||||
|
{category.hint && <p className="text-xs text-gray-500 mt-1">{category.hint}</p>}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">Нет настроек в этой категории.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map(r => (
|
||||||
|
<SettingRow key={r.key} row={r} onSaved={onSaved} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ row, onSaved }) {
|
||||||
|
const [value, setValue] = useState(row.value ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [reveal, setReveal] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// если row.value меняется снаружи — синхронизируем
|
||||||
|
useEffect(() => { setValue(row.value ?? ''); }, [row.value]);
|
||||||
|
|
||||||
|
const isSecret = row.is_secret;
|
||||||
|
const dirty = value !== (row.value ?? '');
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: value === '' ? null : value }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 1500);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маскировка отображения секретов: показываем хвост ***last4 пока не reveal=true
|
||||||
|
const masked = isSecret && value && !reveal
|
||||||
|
? '•'.repeat(Math.max(value.length - 4, 4)) + value.slice(-4)
|
||||||
|
: value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface2/50 p-3">
|
||||||
|
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-sm font-mono">{row.key}</code>
|
||||||
|
{isSecret && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
|
||||||
|
secret
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type={isSecret && !reveal ? 'password' : 'text'}
|
||||||
|
className="input text-sm font-mono"
|
||||||
|
value={reveal || !isSecret ? value : masked}
|
||||||
|
onChange={e => {
|
||||||
|
// При маскированном просмотре редактирование запрещаем — пусть сначала откроют
|
||||||
|
if (isSecret && !reveal) return;
|
||||||
|
setValue(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={isSecret ? '(скрыто)' : '(пусто)'}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{isSecret && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReveal(v => !v)}
|
||||||
|
className="btn-ghost p-2"
|
||||||
|
title={reveal ? 'Скрыть' : 'Показать'}
|
||||||
|
>
|
||||||
|
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: saved ? <Check className="w-4 h-4" />
|
||||||
|
: <Save className="w-4 h-4" />}
|
||||||
|
{saved ? 'Сохранено' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{err && (
|
||||||
|
<div className="text-xs text-red-400 mt-2">{err}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[11px] text-gray-500 mt-2">
|
||||||
|
Категория: <code>{row.category}</code> · обновлено{' '}
|
||||||
|
{row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-1
@@ -21,7 +21,10 @@ async function call(path, options = {}) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
throw new Error(err.error || `Engine ${res.status}`);
|
const e = new Error(err.error || `Engine ${res.status}`);
|
||||||
|
e.status = res.status;
|
||||||
|
e.code = err.code;
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -52,4 +55,17 @@ export const engine = {
|
|||||||
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
|
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
|
||||||
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
|
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
|
||||||
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
|
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
|
||||||
|
|
||||||
|
// Photo search
|
||||||
|
photoSearchProfiles: () => call('/api/photo-search/profiles'),
|
||||||
|
photoSearchQuota: () => call('/api/photo-search/quota'),
|
||||||
|
photoSearchByQuery: (data) => call('/api/photo-search/by-query', { method: 'POST', body: data }),
|
||||||
|
|
||||||
|
// Settings (admin)
|
||||||
|
listSettings: (category) => {
|
||||||
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
|
return call(`/api/settings/admin${qs}`);
|
||||||
|
},
|
||||||
|
updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }),
|
||||||
|
invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-1
@@ -20,5 +20,11 @@ export async function getSession() {
|
|||||||
export async function requireUser() {
|
export async function requireUser() {
|
||||||
const s = await getSession();
|
const s = await getSession();
|
||||||
if (!s.userId) return null;
|
if (!s.userId) return null;
|
||||||
return { id: s.userId, email: s.email, name: s.name };
|
return { id: s.userId, email: s.email, name: s.name, isAdmin: !!s.isAdmin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const u = await requireUser();
|
||||||
|
if (!u || !u.isAdmin) return null;
|
||||||
|
return u;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user