merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2

This commit is contained in:
Alexey Pavlov
2026-06-15 10:28:42 +03:00
95 changed files with 8926 additions and 130 deletions
+165
View File
@@ -0,0 +1,165 @@
# ZeroPost — Roadmap (план фич)
Живой документ. Обновлять по мере выполнения.
Последнее обновление: 2026-06-08
---
## Контекст / архитектура
- **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». Напишу черновик — дай команду.
---
### ✅ СДЕЛАНО СЕГОДНЯ (08.06.2026)
- P1 Календарь: /calendar, месяц/неделя/список, drag&drop, фильтр по каналу
- P2 PostPreview: правая колонка TG/VK/MAX, счётчик символов
- P3 PostTemplates: 7 пресетов структур постов
- P4 ChannelAnalytics: вкладка аналитики, реакции, гистограммы день/час
- P5 FromUrlModal: URL→черновик (веб/YouTube/TG)
### 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) | 50150 подписчиков | 1–2 недели |
| Reddit r/artificial | 50200 переходов | На этой неделе |
| 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.
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}/run`, {
method: 'POST', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue/${params.id}`, {
method: 'DELETE', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue`, {
method: 'POST',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/${params.id}`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/generate`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
// GET — список тем
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics?${searchParams}`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
// POST — добавить тему
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/credit-costs/${params.operation}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/credit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/dashboard`, {
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
cache: 'no-store',
});
return NextResponse.json(await res.json());
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/email/test`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
const res = await fetch(
`${ENGINE_URL}/api/admin/logs?${searchParams}`,
{
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
cache: 'no-store',
}
);
return NextResponse.json(await res.json());
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/plans/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'PATCH', headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'DELETE', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, { headers: h(user.id) });
return NextResponse.json(await res.json());
}
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, {
method: 'POST',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/${params.id}/retry`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
export async function DELETE() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/stuck`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
+16
View File
@@ -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 });
}
}
+17
View File
@@ -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 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/admin/usage/summary?range=today&group_by=service
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 params = {};
if (searchParams.get('range')) params.range = searchParams.get('range');
if (searchParams.get('group_by')) params.group_by = searchParams.get('group_by');
if (searchParams.get('service')) params.service = searchParams.get('service');
const data = await engine.usageSummary(params);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, { headers: h(user.id) });
return NextResponse.json(await res.json());
}
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+22 -4
View File
@@ -16,19 +16,33 @@ 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 });
// Инициализируем баланс нового пользователя (Free план, 50 кредитов)
try {
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
await fetch(`${ENGINE_URL}/api/billing/balance`, {
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
} catch {}
return NextResponse.json({ ok: true, user, isNew: true });
} }
// 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 +55,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 },
});
} }
+13
View File
@@ -0,0 +1,13 @@
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?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const body = await req.json();
const data = await engine.adminCreditUser(body);
return NextResponse.json(data);
} catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); }
}
+12
View File
@@ -0,0 +1,12 @@
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?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const data = await engine.adminGetBalances();
return NextResponse.json(data);
} catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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();
const res = await fetch(`${ENGINE_URL}/api/billing/apply-promo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.getBillingBalance(user.id);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
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 { plan_code } = await req.json();
const data = await engine.call('/api/billing/checkout', {
userId: user.id, method: 'POST', body: { plan_code },
});
return NextResponse.json(data);
} catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET() {
try {
const res = await fetch(`${ENGINE_URL}/api/billing/plans`, {
headers: { 'x-internal-secret': ENGINE_SECRET },
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
try {
const data = await engine.getTransactions(Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/calendar?from=&to=&channel_id=
export async function GET(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const params = {};
if (searchParams.get('from')) params.from = searchParams.get('from');
if (searchParams.get('to')) params.to = searchParams.get('to');
if (searchParams.get('channel_id')) params.channel_id = searchParams.get('channel_id');
try {
const data = await engine.getCalendar(user.id, params);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
// PATCH /api/calendar — reschedule user_post (drag & drop)
export async function PATCH(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, scheduled_at } = await request.json();
if (!id || !scheduled_at) {
return NextResponse.json({ error: 'id and scheduled_at required' }, { status: 400 });
}
try {
const post = await engine.updatePost(user.id, id, { scheduled_at, status: 'scheduled' });
return NextResponse.json(post);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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 { searchParams } = new URL(req.url);
const body = await req.json().catch(() => ({}));
const res = await fetch(
`${ENGINE_URL}/api/channels/${params.id}/drafts/generate?count=${searchParams.get('count') || body.count || 3}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
}
);
return NextResponse.json(await res.json());
}
+26
View File
@@ -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 });
}
}
+2 -1
View File
@@ -21,6 +21,7 @@ export async function POST(req) {
const channel = await engine.createChannel(user.id, body); const channel = await engine.createChannel(user.id, body);
return NextResponse.json(channel); return NextResponse.json(channel);
} catch (err) { } catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 }); const status = err.status === 402 ? 402 : 500;
return NextResponse.json({ error: err.message, code: err.code }, { status });
} }
} }
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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 res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}/reject`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
function h(userId) {
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
}
// PATCH /api/drafts/:id
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
// DELETE /api/drafts/:id
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
function eHeaders(userId) {
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
}
// GET /api/drafts — все черновики пользователя
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const res = await fetch(`${ENGINE_URL}/api/drafts?${searchParams}`, { headers: eHeaders(user.id) });
return NextResponse.json(await res.json());
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function POST(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
if (!body.channelId || !body.url) {
return NextResponse.json({ error: 'channelId and url required' }, { status: 400 });
}
try {
const data = await engine.generateFromUrl(user.id, body);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+26
View File
@@ -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 });
}
}
@@ -0,0 +1,16 @@
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 GET(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const res = await fetch(
`${ENGINE_URL}/api/inbox/${params.channelId}?${searchParams.toString()}`,
{ headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) } }
);
return NextResponse.json(await res.json());
}
@@ -0,0 +1,15 @@
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 res = await fetch(`${ENGINE_URL}/api/inbox/${params.channelId}/setup-webhook`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+17
View File
@@ -0,0 +1,17 @@
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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/inbox/${params.id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,17 @@
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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/inbox/${params.id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(request, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
try {
const { channelId } = await params;
const data = await engine.getBestTime(channelId, Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(request, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
try {
const { channelId } = await params;
const data = await engine.getChannelMetrics(channelId, Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function PATCH(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { id } = await params;
const body = await req.json();
const note = await engine.updateNote(id, body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { id } = await params;
await engine.deleteNote(id);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const notes = await engine.listNotes();
return NextResponse.json(notes);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const body = await req.json();
const note = await engine.createNote(body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+18
View File
@@ -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 }
);
}
}
+14
View File
@@ -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 });
}
}
+14
View File
@@ -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,17 @@
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();
const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,14 @@
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 res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}/refill`, {
method: 'POST', headers: { 'x-internal-secret': ENGINE_SECRET },
});
return NextResponse.json(await res.json());
}
+14
View File
@@ -0,0 +1,14 @@
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 || '';
const h = { 'x-internal-secret': ENGINE_SECRET };
export async function GET(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}?${searchParams}`, { headers: h });
return NextResponse.json(await res.json());
}
+14
View File
@@ -0,0 +1,14 @@
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 DELETE(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/item/${params.id}`, {
method: 'DELETE', headers: { 'x-internal-secret': ENGINE_SECRET },
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
try {
const data = await engine.usageSummary(Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useState, useEffect } from 'react';
import { Coins, RefreshCw, TrendingDown, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
const TYPE_LABELS = {
spend_image: { label: 'Генерация картинки', sign: '-', color: 'text-red-400' },
spend_text_post: { label: 'Генерация поста', sign: '-', color: 'text-red-400' },
spend_article: { label: 'Генерация статьи', sign: '-', color: 'text-red-400' },
spend_autopublish:{ label: 'Публикация', sign: '-', color: 'text-gray-400' },
plan_credit: { label: 'Начисление по тарифу',sign: '+', color: 'text-green-400' },
topup: { label: 'Пополнение', sign: '+', color: 'text-green-400' },
bonus: { label: 'Бонус', sign: '+', color: 'text-blue-400' },
refund: { label: 'Возврат', sign: '+', color: 'text-blue-400' },
};
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
}
export default function BillingPage() {
const [balance, setBalance] = useState(null);
const [txs, setTxs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const PER_PAGE = 30;
async function load(p = 0) {
setLoading(true);
try {
const [balRes, txRes] = await Promise.all([
fetch('/api/billing/balance').then(r => r.json()),
fetch(`/api/billing/transactions?limit=${PER_PAGE}&offset=${p * PER_PAGE}`).then(r => r.json()),
]);
setBalance(balRes);
setTxs(txRes.transactions || []);
setTotal(txRes.total || 0);
} catch {}
setLoading(false);
}
useEffect(() => { load(0); }, []);
const PLAN_COLORS = { free: 'text-gray-400', starter: 'text-blue-400', pro: 'text-purple-400', business: 'text-yellow-400' };
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<BackButton />
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold flex items-center gap-2">
<Coins className="w-5 h-5 text-accent" /> Баланс и кредиты
</h1>
<button onClick={() => load(page)} className="btn-ghost p-2">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Баланс */}
{balance && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="card p-4 col-span-2 sm:col-span-1 border-accent/40 bg-accent/5">
<div className="text-3xl font-bold text-accent">
{balance.isUnlimited ? '∞' : balance.credits}
</div>
<div className="text-xs text-gray-400 mt-1">кредитов осталось</div>
</div>
<div className="card p-4">
<div className={`text-lg font-bold ${PLAN_COLORS[balance.plan] || 'text-gray-300'}`}>
{balance.planName}
</div>
<div className="text-xs text-gray-400 mt-1">текущий тариф</div>
</div>
<div className="card p-4">
<div className="text-sm font-medium text-gray-300">
{balance.resetAt ? new Date(balance.resetAt).toLocaleDateString('ru-RU') : '—'}
</div>
<div className="text-xs text-gray-400 mt-1">сброс кредитов</div>
</div>
</div>
)}
{/* CTA апгрейд */}
{balance?.plan === 'free' && (
<div className="card p-4 mb-6 border-accent/30 bg-accent/5 flex items-center justify-between">
<div>
<div className="font-medium text-sm">Хотите больше кредитов?</div>
<div className="text-xs text-gray-400 mt-0.5">Starter 500 кредитов за 490/мес</div>
</div>
<Link href="/plans" className="btn-primary text-sm px-4 py-1.5 flex items-center gap-1">
Тарифы <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
{/* Промокод */}
<PromoForm onApplied={() => load(0)} />
{/* Стоимость операций */}
<div className="card p-4 mb-6">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">Стоимость операций</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ label: 'Картинка', credits: 5, icon: '🖼' },
{ label: 'Пост', credits: 2, icon: '✍️' },
{ label: 'Статья', credits: 5, icon: '📝' },
{ label: 'Публикация', credits: 0, icon: '📤' },
].map(op => (
<div key={op.label} className="text-center p-2 rounded-lg bg-surface2">
<div className="text-lg">{op.icon}</div>
<div className="text-xs text-gray-300 mt-1">{op.label}</div>
<div className="text-sm font-bold text-accent mt-0.5">
{op.credits === 0 ? 'бесплатно' : `${op.credits} кр`}
</div>
</div>
))}
</div>
</div>
{/* История транзакций */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">История</h2>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && (
<div className="card overflow-hidden">
{txs.length === 0 && (
<div className="py-8 text-center text-gray-500 text-sm">Транзакций пока нет</div>
)}
{txs.map(tx => {
const meta = TYPE_LABELS[tx.type] || { label: tx.type, sign: tx.amount > 0 ? '+' : '-', color: 'text-gray-400' };
return (
<div key={tx.id} className="flex items-center justify-between px-4 py-3 border-b border-border last:border-0 hover:bg-surface2/50">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{tx.description || meta.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{fmtDate(tx.created_at)}</div>
</div>
<div className="text-right ml-4">
<div className={`font-bold text-sm ${meta.color}`}>
{meta.sign}{Math.abs(tx.amount)} кр
</div>
<div className="text-xs text-gray-500">= {tx.balance_after === -1 ? '∞' : tx.balance_after} кр</div>
</div>
</div>
);
})}
</div>
)}
{/* Пагинация */}
{total > PER_PAGE && (
<div className="flex justify-center gap-2 mt-4">
<button disabled={page === 0} onClick={() => { setPage(p => p-1); load(page-1); }} className="btn-ghost px-3 py-1.5 text-sm disabled:opacity-40"> Назад</button>
<span className="text-sm text-gray-500 self-center">{page+1} / {Math.ceil(total/PER_PAGE)}</span>
<button disabled={(page+1)*PER_PAGE >= total} onClick={() => { setPage(p => p+1); load(page+1); }} className="btn-ghost px-3 py-1.5 text-sm disabled:opacity-40">Вперёд </button>
</div>
)}
</main>
);
}
function PromoForm({ onApplied }) {
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const [show, setShow] = useState(false);
async function apply() {
if (!code.trim()) return;
setBusy(true);
const res = await fetch('/api/billing/apply-promo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase() }),
}).then(r => r.json());
setBusy(false);
if (res.ok) {
setMsg(res.message);
setCode('');
setShow(false);
onApplied?.();
} else {
setMsg('Ошибка: ' + (res.error || 'неизвестно'));
}
setTimeout(() => setMsg(''), 4000);
}
return (
<div className="mb-6">
{!show ? (
<button onClick={() => setShow(true)} className="text-sm text-gray-500 hover:text-accent transition-colors">
🎁 Есть промокод?
</button>
) : (
<div className="card p-4">
<div className="flex gap-2">
<input
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && apply()}
placeholder="ВВЕДИТЕ КОД"
className="input flex-1 font-mono text-sm py-1.5 tracking-widest"
autoFocus
maxLength={32}
/>
<button onClick={apply} disabled={busy || !code.trim()} className="btn-primary px-4 text-sm">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Применить'}
</button>
<button onClick={() => { setShow(false); setCode(''); }} className="btn-ghost px-3 text-sm"></button>
</div>
{msg && <p className={`text-xs mt-2 ${msg.startsWith('Ошибка') ? 'text-red-400' : 'text-green-400'}`}>{msg}</p>}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import CalendarView from '@/components/CalendarView';
export const metadata = { title: 'Календарь публикаций — ZeroPost' };
export default async function CalendarPage() {
const user = await requireUser();
if (!user) redirect('/login');
let channels = [];
try {
channels = await engine.listChannels(user.id);
} catch (e) {
console.error('[Calendar] listChannels failed:', e.message);
}
return (
<>
<Header user={user} />
<main className="max-w-7xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Календарь публикаций</h1>
<p className="text-sm text-gray-500 mt-1">
Планируй и отслеживай выход постов по всем каналам
</p>
</div>
</div>
<CalendarView channels={channels} />
</main>
</>
);
}
+72 -17
View File
@@ -49,7 +49,8 @@ export default function NewChannelPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [niche, setNiche] = useState(''); const [niche, setNiche] = useState('');
const [audience, setAudience] = useState(''); const [audience, setAudience] = useState('');
const [goal, setGoal] = useState('educational'); const [goals, setGoals] = useState(['educational']); // multi-select, отправляем как CSV
const [customGoal, setCustomGoal] = useState(''); // поле для своей цели
const [language, setLanguage] = useState('ru'); const [language, setLanguage] = useState('ru');
// Шаг 2 — стиль // Шаг 2 — стиль
@@ -70,7 +71,7 @@ export default function NewChannelPage() {
setBusy(true); setBusy(true);
setError(''); setError('');
const data = { const data = {
name, niche, audience, goal, language, region: 'ru', name, niche, audience, goal: goals.join(','), language, region: 'ru',
style: { style: {
tone, formality, humor, tone, formality, humor,
post_length: postLength, post_length: postLength,
@@ -88,7 +89,16 @@ export default function NewChannelPage() {
}); });
const json = await res.json(); const json = await res.json();
setBusy(false); setBusy(false);
if (!res.ok) { setError(json.error || 'Ошибка'); return; } if (!res.ok) {
if (json.code === 'CHANNEL_LIMIT_REACHED') {
setError(`${json.error}`);
// Перенаправим на страницу тарифов через 2 сек
setTimeout(() => router.push('/plans'), 2000);
} else {
setError(json.error || 'Ошибка');
}
return;
}
router.push(`/channels/${json.id}`); router.push(`/channels/${json.id}`);
} }
@@ -150,22 +160,67 @@ export default function NewChannelPage() {
/> />
</div> </div>
<div> <div>
<label className="label">Цель канала</label> <label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{GOALS.map(g => ( {GOALS.map(g => {
<button const on = goals.includes(g.v);
key={g.v} return (
type="button" <button
onClick={() => setGoal(g.v)} key={g.v}
className={`p-2.5 rounded-lg border text-left transition-colors ${ type="button"
goal === g.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600' onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
}`} className={`p-2.5 rounded-lg border text-left transition-colors ${
> on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
<div className="text-sm font-medium">{g.label}</div> }`}
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div> >
</button> <div className="text-sm font-medium">{g.label}</div>
))} <div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
</button>
);
})}
</div> </div>
{/* Своя цель */}
<div className="flex gap-2 mt-2">
<input
className="input text-sm flex-1"
placeholder="Своя цель — введи и нажми +"
value={customGoal}
onChange={e => setCustomGoal(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
const v = customGoal.trim();
if (v && !goals.includes(v)) setGoals([...goals, v]);
setCustomGoal('');
}
}}
/>
<button
type="button"
onClick={() => {
const v = customGoal.trim();
if (v && !goals.includes(v)) setGoals([...goals, v]);
setCustomGoal('');
}}
disabled={!customGoal.trim()}
className="btn-primary px-3 disabled:opacity-40"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Выбранные кастомные цели — чипы */}
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
{g}
<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))} className="hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div> </div>
<div> <div>
<label className="label">Язык постов</label> <label className="label">Язык постов</label>
+225
View File
@@ -0,0 +1,225 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Clock, Check, X, Edit3, Trash2, RefreshCw, Loader2, Calendar, Image as ImgIcon, Zap } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
const STATUS_TABS = [
{ v: 'pending', label: 'Ожидают', color: 'text-accent' },
{ v: 'approved', label: 'Одобрено', color: 'text-green-400' },
{ v: 'rejected', label: 'Отклонено', color: 'text-gray-500' },
];
function timeAgo(s) {
const d = new Date(s), now = new Date();
const diff = now - d;
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'ч назад';
return d.toLocaleDateString('ru-RU');
}
export default function DraftsPage() {
const [tab, setTab] = useState('pending');
const [drafts, setDrafts] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading]= useState(true);
const [editing, setEditing]= useState(null); // draft id
const [editText,setEditText]=useState('');
const [schedMap,setSchedMap]=useState({}); // draftId → scheduledAt
const [busy, setBusy] = useState({});
const load = useCallback(async (t = tab) => {
setLoading(true);
try {
const res = await fetch(`/api/drafts?status=${t}&limit=50`).then(r => r.json());
setDrafts(res.drafts || []);
setTotal(res.total || 0);
} catch {}
setLoading(false);
}, [tab]);
useEffect(() => { load(tab); }, [tab]);
async function doApprove(id) {
setBusy(b => ({ ...b, [id]: true }));
const res = await fetch(`/api/drafts/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scheduled_at: schedMap[id] || null }),
}).then(r => r.json());
setBusy(b => ({ ...b, [id]: false }));
if (res.ok) load(tab); else alert(res.error);
}
async function doReject(id) {
setBusy(b => ({ ...b, [id]: true }));
await fetch(`/api/drafts/${id}/reject`, { method: 'POST' });
setBusy(b => ({ ...b, [id]: false }));
load(tab);
}
async function doDelete(id) {
if (!confirm('Удалить черновик?')) return;
await fetch(`/api/drafts/${id}`, { method: 'DELETE' });
load(tab);
}
async function saveEdit(id) {
await fetch(`/api/drafts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: editText }),
});
setEditing(null);
load(tab);
}
const pendingCount = tab === 'pending' ? total : 0;
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<BackButton />
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Zap className="w-5 h-5 text-accent" /> Черновики
</h1>
<p className="text-sm text-gray-400 mt-0.5">
{tab === 'pending' && total > 0
? `${total} ${total === 1 ? 'пост ждёт' : 'поста ждут'} одобрения`
: 'Авто-генерированные и пакетные посты на проверку'}
</p>
</div>
<button onClick={() => load(tab)} className="btn-ghost p-2">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Табы */}
<div className="flex gap-1 mb-5">
{STATUS_TABS.map(t => (
<button key={t.v} onClick={() => setTab(t.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
tab === t.v ? `bg-accent/10 ${t.color} font-medium` : 'text-gray-500 hover:text-gray-300'
}`}>
{t.label}
</button>
))}
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && drafts.length === 0 && (
<div className="py-16 text-center text-gray-500">
<Zap className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">
{tab === 'pending'
? <>Нет черновиков. Включите авто-генерацию в <Link href="/" className="text-accent hover:underline">настройках канала</Link> или сгенерируйте вручную.</>
: 'Нет записей'}
</div>
</div>
)}
<div className="space-y-4">
{drafts.map(draft => (
<div key={draft.id} className="card p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-300">{draft.channel_name}</span>
{draft.platform && <span className="text-xs text-gray-500 px-1.5 py-0.5 bg-surface2 rounded">{draft.platform}</span>}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Clock className="w-3 h-3" />
{timeAgo(draft.created_at)}
</div>
</div>
{/* Тема */}
{draft.topic && (
<div className="text-xs text-accent/80 flex items-center gap-1">
<span>💡</span> {draft.topic}
</div>
)}
{/* Текст */}
{editing === draft.id ? (
<div className="space-y-2">
<textarea
rows={6}
value={editText}
onChange={e => setEditText(e.target.value)}
className="input w-full text-sm resize-none font-mono"
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => saveEdit(draft.id)} className="btn-primary text-sm px-3 py-1.5">Сохранить</button>
<button onClick={() => setEditing(null)} className="btn-ghost text-sm px-3 py-1.5">Отмена</button>
</div>
</div>
) : (
<div className="text-sm text-gray-200 whitespace-pre-wrap leading-relaxed bg-surface2 rounded-lg p-3 max-h-48 overflow-y-auto">
{draft.text}
</div>
)}
{/* Изображение */}
{draft.image_url && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<ImgIcon className="w-3.5 h-3.5" />
<span>Картинка прикреплена</span>
<a href={draft.image_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">просмотр</a>
</div>
)}
{/* Действия */}
{draft.status === 'pending' && (
<div className="flex flex-wrap items-center gap-2 pt-1">
{/* Время публикации */}
<input
type="datetime-local"
value={schedMap[draft.id] || ''}
onChange={e => setSchedMap(m => ({ ...m, [draft.id]: e.target.value }))}
className="input text-xs py-1.5 w-48"
title="Время публикации (оставьте пустым для ближайшего слота)"
/>
<button onClick={() => doApprove(draft.id)} disabled={busy[draft.id]}
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
{busy[draft.id] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Одобрить
</button>
<button onClick={() => { setEditing(draft.id); setEditText(draft.text); }}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Edit3 className="w-3.5 h-3.5" /> Редактировать
</button>
<button onClick={() => doReject(draft.id)} disabled={busy[draft.id]}
className="btn-ghost text-sm px-3 py-1.5 text-gray-500 flex items-center gap-1.5">
<X className="w-3.5 h-3.5" /> Отклонить
</button>
</div>
)}
{draft.status === 'approved' && (
<div className="flex items-center gap-2 text-xs text-green-400">
<Check className="w-3.5 h-3.5" />
Одобрен · запланирован на {draft.scheduled_at ? new Date(draft.scheduled_at).toLocaleString('ru-RU') : '—'}
</div>
)}
{draft.status === 'rejected' && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Отклонён</span>
<button onClick={() => doDelete(draft.id)} className="btn-ghost p-1.5 text-gray-600 hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
))}
</div>
</main>
);
}
+173
View File
@@ -0,0 +1,173 @@
import Link from 'next/link';
import { Sparkles, Zap, Calendar, BarChart3, MessageCircle, Globe, ArrowRight, Check } from 'lucide-react';
const FEATURES = [
{ icon: Zap, title: 'AI генерация постов', desc: 'Claude пишет посты под твою нишу и стиль. Тексты, которые хочется читать.' },
{ icon: Calendar, title: 'Отложенная публикация', desc: 'Планируй контент на неделю вперёд. Автопостинг в нужное время.' },
{ icon: Globe, title: 'Telegram, VK, MAX', desc: 'Один интерфейс для всех платформ. Публикуй везде одновременно.' },
{ icon: Sparkles, title: 'Авто-черновики', desc: 'Каждое утро 3 новых поста на проверку. Ты только одобряешь лучшее.' },
{ icon: BarChart3, title: 'Аналитика канала', desc: 'Видишь что работает. Охват, реакции, лучшее время для публикации.' },
{ icon: MessageCircle, title: 'Inbox и AI-ответы', desc: 'Комментарии приходят в одно место. AI предлагает ответы за тебя.' },
];
const PLANS = [
{
name: 'Free', price: 0, credits: 50, channels: 1,
features: ['1 канал', '50 кредитов/мес', 'AI генерация постов', 'Планировщик'],
cta: 'Начать бесплатно', ctaHref: '/register', accent: false,
},
{
name: 'Starter', price: 490, credits: 500, channels: 2,
features: ['2 канала', '500 кредитов/мес', 'Авто-черновики', 'Аналитика', 'Inbox'],
cta: 'Попробовать', ctaHref: '/register', accent: true,
},
{
name: 'Pro', price: 1490, credits: 2000, channels: 5,
features: ['5 каналов', '2000 кредитов/мес', 'Все платформы', 'Хештеги AI', 'Опросы TG'],
cta: 'Выбрать Pro', ctaHref: '/register', accent: false,
},
{
name: 'Business', price: 3990, credits: -1, channels: -1,
features: ['Безлимит каналов', 'Безлимит кредитов', 'Приоритетная поддержка', 'API доступ'],
cta: 'Связаться', ctaHref: 'mailto:hello@zeropost.ru', accent: false,
},
];
export default function LandingPage() {
return (
<div className="min-h-screen bg-background text-text">
{/* Nav */}
<nav className="border-b border-border sticky top-0 bg-background/90 backdrop-blur z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<Sparkles className="w-5 h-5 text-accent" /> ZeroPost
</Link>
<div className="flex items-center gap-3">
<Link href="/login" className="btn-ghost text-sm px-4 py-2">Войти</Link>
<Link href="/register" className="btn-primary text-sm px-4 py-2">Попробовать бесплатно</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="max-w-4xl mx-auto px-4 sm:px-6 pt-20 pb-16 text-center">
<div className="inline-flex items-center gap-2 text-xs text-accent bg-accent/10 px-3 py-1.5 rounded-full mb-6">
<Sparkles className="w-3.5 h-3.5" /> AI-контент для Telegram и VK
</div>
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-5">
Ведите канал на автопилоте.<br />
<span className="text-accent">AI пишет, ты одобряешь.</span>
</h1>
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-8">
ZeroPost генерирует посты для Telegram и VK, планирует публикации и отвечает на комментарии.
Тратьте 10 минут в день вместо 2 часов.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link href="/register"
className="btn-primary px-6 py-3 text-base flex items-center gap-2">
Начать бесплатно <ArrowRight className="w-4 h-4" />
</Link>
<Link href="/login" className="btn-ghost px-6 py-3 text-base">
Уже есть аккаунт
</Link>
</div>
<p className="text-xs text-gray-500 mt-4">50 кредитов бесплатно · Без карты</p>
</section>
{/* Features */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-10">Что умеет ZeroPost</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map(f => (
<div key={f.title} className="card p-5">
<f.icon className="w-8 h-8 text-accent mb-3" />
<h3 className="font-semibold mb-2">{f.title}</h3>
<p className="text-sm text-gray-400 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</section>
{/* How it works */}
<section className="max-w-4xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-10">Как это работает</h2>
<div className="grid gap-6 sm:grid-cols-3">
{[
{ step: '1', title: 'Добавь канал', desc: 'Подключи Telegram, VK или MAX. Укажи нишу и стиль.' },
{ step: '2', title: 'AI генерирует', desc: 'Каждое утро — свежие черновики. Редактируй, одобряй.' },
{ step: '3', title: 'Публикуй в один клик', desc: 'Запланируй или публикуй сейчас. Всё само.' },
].map(s => (
<div key={s.step} className="text-center">
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent font-bold text-lg flex items-center justify-center mx-auto mb-3">
{s.step}
</div>
<h3 className="font-semibold mb-2">{s.title}</h3>
<p className="text-sm text-gray-400">{s.desc}</p>
</div>
))}
</div>
</section>
{/* Pricing */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-2">Тарифы</h2>
<p className="text-gray-400 text-center text-sm mb-10">Начни бесплатно, масштабируй по мере роста</p>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map(plan => (
<div key={plan.name} className={`card p-5 flex flex-col ${plan.accent ? 'border-accent bg-accent/5' : ''}`}>
{plan.accent && (
<div className="text-xs text-accent font-medium mb-2"> Популярный</div>
)}
<div className="font-bold text-lg">{plan.name}</div>
<div className="text-3xl font-bold mt-1 mb-1">
{plan.price === 0 ? <span className="text-accent">0</span> : `${plan.price}`}
{plan.price > 0 && <span className="text-sm font-normal text-gray-500">/мес</span>}
</div>
<div className="text-xs text-gray-500 mb-4">
{plan.credits === -1 ? '∞ кредитов' : `${plan.credits} кредитов/мес`}
</div>
<ul className="space-y-2 flex-1 mb-5">
{plan.features.map(f => (
<li key={f} className="text-sm text-gray-300 flex items-center gap-2">
<Check className="w-3.5 h-3.5 text-green-400 shrink-0" /> {f}
</li>
))}
</ul>
<Link href={plan.ctaHref}
className={`py-2.5 px-4 rounded-lg text-sm font-medium text-center transition-colors ${
plan.accent ? 'btn-primary' : 'btn-ghost border border-border'
}`}>
{plan.cta}
</Link>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="max-w-2xl mx-auto px-4 sm:px-6 pb-20 text-center">
<h2 className="text-3xl font-bold mb-4">Готовы попробовать?</h2>
<p className="text-gray-400 mb-6">50 бесплатных кредитов. Без карты. Настройка за 5 минут.</p>
<Link href="/register" className="btn-primary px-8 py-3 text-base inline-flex items-center gap-2">
Создать аккаунт <ArrowRight className="w-4 h-4" />
</Link>
</section>
{/* Footer */}
<footer className="border-t border-border py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-wrap items-center justify-between gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
<span className="font-medium text-gray-300">ZeroPost</span>
<span>· AI-автоматизация контента</span>
</div>
<div className="flex gap-4">
<Link href="/login" className="hover:text-gray-300">Войти</Link>
<Link href="/register" className="hover:text-gray-300">Регистрация</Link>
<a href="mailto:hello@zeropost.ru" className="hover:text-gray-300">Контакты</a>
</div>
</div>
</footer>
</div>
);
}
+2 -1
View File
@@ -27,7 +27,8 @@ export default function LoginPage() {
setError(data.error || 'Ошибка'); setError(data.error || 'Ошибка');
return; return;
} }
router.push('/'); // Новый пользователь → онбординг, существующий → главная
router.push(data.isNew ? '/onboarding' : '/');
} }
return ( return (
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useState, useEffect } from 'react';
import { Pin, PinOff, Trash2, Plus, Save, Eye, EyeOff, Loader2, MessageCircle, Check, ExternalLink } from 'lucide-react';
const EMPTY = { title: '', content: '', author: 'Редактор', is_pinned: false };
export default function NotesPage() {
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | note object
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
async function load() {
setLoading(true);
try {
const r = await fetch('/api/notes');
setNotes(await r.json());
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
function startNew() {
setForm(EMPTY);
setEditing('new');
setErr('');
}
function startEdit(note) {
setForm({ title: note.title || '', content: note.content, author: note.author, is_pinned: note.is_pinned });
setEditing(note);
setErr('');
}
async function save() {
if (!form.content.trim()) { setErr('Текст заметки обязателен'); return; }
setSaving(true); setErr('');
try {
const body = { ...form, title: form.title.trim() || null };
if (editing === 'new') {
await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} else {
await fetch(`/api/notes/${editing.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
setEditing(null);
await load();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
}
async function togglePublish(note) {
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_published: !note.is_published }) });
await load();
}
async function togglePin(note) {
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_pinned: !note.is_pinned }) });
await load();
}
async function del(note) {
if (!confirm(`Удалить заметку «${note.title || note.content.slice(0, 40)}»?`)) return;
await fetch(`/api/notes/${note.id}`, { method: 'DELETE' });
await load();
}
const fmt = (d) => new Date(d).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold">Заметки редактора</h1>
<a href="https://zeropost.ru/notes" target="_blank" rel="noreferrer" className="text-gray-500 hover:text-accent">
<ExternalLink className="w-4 h-4" />
</a>
</div>
<button onClick={startNew} className="btn-primary text-sm flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Новая заметка
</button>
</div>
{/* Форма создания/редактирования */}
{editing && (
<div className="card p-5 mb-5 border-accent/30">
<div className="text-sm font-semibold mb-3">{editing === 'new' ? 'Новая заметка' : 'Редактировать'}</div>
<input
className="input text-sm mb-3"
placeholder="Заголовок (опц.)"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
/>
<textarea
className="input text-sm min-h-[120px] mb-3"
placeholder="Текст заметки. Коротко и по делу — виден блок на главной странице."
value={form.content}
onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
maxLength={1000}
/>
<div className="flex items-center gap-4 mb-3">
<input
className="input text-sm flex-1"
placeholder="Автор"
value={form.author}
onChange={e => setForm(f => ({ ...f, author: e.target.value }))}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input type="checkbox" checked={form.is_pinned} onChange={e => setForm(f => ({ ...f, is_pinned: e.target.checked }))} className="accent-accent w-4 h-4" />
Закрепить
</label>
</div>
<div className="text-xs text-gray-500 mb-3">{form.content.length}/1000 символов</div>
{err && <div className="text-xs text-red-400 mb-3">{err}</div>}
<div className="flex gap-2">
<button onClick={save} disabled={saving} className="btn-primary text-sm">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Сохраняю...' : 'Сохранить'}
</button>
<button onClick={() => setEditing(null)} className="btn-ghost text-sm">Отмена</button>
</div>
</div>
)}
{loading && <div className="text-center py-12"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && notes.length === 0 && (
<div className="card p-8 text-center text-gray-500 text-sm">
Заметок пока нет. Нажми «Новая заметка» чтобы добавить первую.
</div>
)}
<div className="space-y-3">
{notes.map(note => (
<div key={note.id} className={`card p-4 ${!note.is_published ? 'opacity-60' : ''}`}>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{note.is_pinned && (
<div className="flex items-center gap-1 text-xs text-accent mb-1">
<Pin className="w-3 h-3" /> закреплено
</div>
)}
{note.title && <div className="font-semibold text-sm mb-1">{note.title}</div>}
<p className="text-sm text-gray-300 whitespace-pre-line line-clamp-4">{note.content}</p>
<div className="text-xs text-gray-500 mt-2">{note.author} · {fmt(note.created_at)}{!note.is_published ? ' · скрыта' : ''}</div>
</div>
<div className="flex flex-col gap-1 shrink-0">
<button onClick={() => startEdit(note)} className="btn-ghost p-1.5 text-xs" title="Редактировать"></button>
<button onClick={() => togglePin(note)} className="btn-ghost p-1.5" title={note.is_pinned ? 'Открепить' : 'Закрепить'}>
{note.is_pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<button onClick={() => togglePublish(note)} className="btn-ghost p-1.5" title={note.is_published ? 'Скрыть' : 'Показать'}>
{note.is_published ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={() => del(note)} className="btn-ghost p-1.5 hover:text-red-400" title="Удалить">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</main>
);
}
+178
View File
@@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Sparkles, CheckCircle, ArrowRight, Loader2, Bot, Hash, Zap } from 'lucide-react';
const PLATFORMS = [
{ v: 'telegram', label: 'Telegram', icon: '✈️', desc: 'Канал или группа' },
{ v: 'vk', label: 'ВКонтакте', icon: '🔵', desc: 'Группа или паблик' },
{ v: 'max', label: 'MAX', icon: '🟣', desc: 'Мессенджер MAX' },
];
const NICHES = [
'Технологии и ИИ', 'Бизнес и финансы', 'Маркетинг и SMM',
'Здоровье и спорт', 'Образование', 'Развлечения', 'Новости',
'Другое',
];
export default function OnboardingPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [platform, setPlatform] = useState('telegram');
const [name, setName] = useState('');
const [niche, setNiche] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const [channel, setChannel] = useState(null);
async function createChannel() {
if (!name.trim()) { setError('Введите название канала'); return; }
setBusy(true);
setError('');
try {
const res = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), platform, niche }),
}).then(r => r.json());
if (res.error) { setError(res.error); setBusy(false); return; }
setChannel(res);
setDone(true);
setStep(3);
} catch { setError('Ошибка соединения'); }
setBusy(false);
}
return (
<main className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-xl">
{/* Progress */}
<div className="flex items-center justify-center gap-2 mb-8">
{[1,2,3].map(n => (
<div key={n} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors ${
step > n ? 'bg-green-500 text-white' :
step === n ? 'bg-accent text-white' :
'bg-surface2 text-gray-500'
}`}>
{step > n ? <CheckCircle className="w-4 h-4" /> : n}
</div>
{n < 3 && <div className={`w-12 h-0.5 ${step > n ? 'bg-green-500' : 'bg-surface2'}`} />}
</div>
))}
</div>
<div className="card p-6 sm:p-8">
{/* Шаг 1 — платформа */}
{step === 1 && (
<>
<div className="text-center mb-6">
<div className="text-3xl mb-2">👋</div>
<h1 className="text-xl font-bold">Добро пожаловать!</h1>
<p className="text-gray-400 text-sm mt-1">Создадим первый канал за пару минут</p>
</div>
<p className="text-sm font-medium mb-3">Выберите платформу:</p>
<div className="grid grid-cols-3 gap-3 mb-6">
{PLATFORMS.map(p => (
<button key={p.v} onClick={() => setPlatform(p.v)}
className={`p-4 rounded-xl border-2 text-center transition-all ${
platform === p.v ? 'border-accent bg-accent/10' : 'border-border hover:border-accent/40'
}`}>
<div className="text-2xl mb-1">{p.icon}</div>
<div className="text-sm font-medium">{p.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{p.desc}</div>
</button>
))}
</div>
<button onClick={() => setStep(2)} className="btn-primary w-full py-3 flex items-center justify-center gap-2">
Далее <ArrowRight className="w-4 h-4" />
</button>
</>
)}
{/* Шаг 2 — название и ниша */}
{step === 2 && (
<>
<div className="text-center mb-6">
<h1 className="text-xl font-bold">Расскажите о канале</h1>
<p className="text-gray-400 text-sm mt-1">AI будет генерировать контент в нужном стиле</p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="label mb-1.5">Название канала *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="input w-full"
placeholder="Например: Tech Insider RU"
autoFocus
/>
</div>
<div>
<label className="label mb-1.5">Ниша / тематика</label>
<div className="flex flex-wrap gap-2">
{NICHES.map(n => (
<button key={n} onClick={() => setNiche(n === niche ? '' : n)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
niche === n ? 'border-accent bg-accent/10 text-accent' : 'border-border hover:border-accent/40'
}`}>
{n}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-ghost px-4">Назад</button>
<button onClick={createChannel} disabled={busy || !name.trim()}
className="btn-primary flex-1 py-3 flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Sparkles className="w-4 h-4" />Создать канал</>}
</button>
</div>
</>
)}
{/* Шаг 3 — готово */}
{step === 3 && (
<>
<div className="text-center mb-6">
<div className="text-4xl mb-3">🎉</div>
<h1 className="text-xl font-bold">Канал создан!</h1>
<p className="text-gray-400 text-sm mt-1">Что делать дальше:</p>
</div>
<div className="space-y-3 mb-6">
{[
{ icon: Bot, text: 'Подключите бота Telegram в настройках канала', color: 'text-blue-400' },
{ icon: Hash, text: 'AI сгенерирует темы постов автоматически', color: 'text-purple-400' },
{ icon: Zap, text: 'Напишите первый пост с помощью AI', color: 'text-yellow-400' },
].map(({ icon: Icon, text, color }) => (
<div key={text} className="flex items-start gap-3 p-3 rounded-lg bg-surface2">
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<span className="text-sm">{text}</span>
</div>
))}
</div>
<div className="space-y-2">
<button onClick={() => router.push(channel ? `/channels/${channel.id}` : '/')}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
Начать работу <ArrowRight className="w-4 h-4" />
</button>
<button onClick={() => router.push(channel ? `/channels/${channel.id}/edit` : '/')}
className="btn-ghost w-full py-2.5 text-sm text-gray-400">
Настроить канал подробнее
</button>
</div>
</>
)}
</div>
<p className="text-center text-xs text-gray-600 mt-4">
У вас 50 бесплатных кредитов для старта
</p>
</div>
</main>
);
}
+27 -30
View File
@@ -15,7 +15,7 @@ const GOAL_LABELS = {
export default async function HomePage() { export default async function HomePage() {
const user = await requireUser(); const user = await requireUser();
if (!user) redirect('/login'); if (!user) redirect('/landing');
let channels = []; let channels = [];
try { try {
@@ -43,50 +43,47 @@ export default async function HomePage() {
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-surface2 mb-4"> <MessageSquare className="w-12 h-12 mx-auto mb-4 text-accent opacity-50" />
<MessageSquare className="w-7 h-7 text-gray-500" /> <h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
</div> <p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
<p className="text-sm text-gray-500 mb-6">
Создай первый канал, чтобы начать генерировать посты
</p>
<Link href="/channels/new" className="btn-primary"> <Link href="/channels/new" className="btn-primary">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Создать канал Создать первый канал
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map(ch => ( {channels.map(ch => (
<Link <Link key={ch.id} href={`/channels/${ch.id}`} className="card p-5 hover:border-accent/40 transition-colors group">
key={ch.id}
href={`/channels/${ch.id}`}
className="card p-5 hover:border-accent/40 transition-colors group"
>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<h3 className="font-semibold group-hover:text-accent transition-colors"> <div>
{ch.name} <h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
</h3> {ch.tg_username && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400"> <span className="text-xs text-gray-500">@{ch.tg_username}</span>
{GOAL_LABELS[ch.goal] || ch.goal} )}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
ch.platform === 'telegram' ? 'bg-blue-500/20 text-blue-400' :
ch.platform === 'vk' ? 'bg-blue-600/20 text-blue-500' :
'bg-purple-500/20 text-purple-400'
}`}>
{ch.platform || 'telegram'}
</span> </span>
</div> </div>
{ch.niche && ( {ch.niche && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3"> <p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
{ch.niche}
</p>
)} )}
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
{ch.audience && ( {ch.goal && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3" /> <Target className="w-3 h-3" />
Есть ЦА {GOAL_LABELS[ch.goal] || ch.goal}
</span> </span>
)} )}
{ch.style?.example_posts?.length > 0 && ( {ch.language && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Target className="w-3 h-3 text-accent" /> <Users className="w-3 h-3" />
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'} {ch.language.toUpperCase()}
</span> </span>
)} )}
</div> </div>
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useState, useEffect } from 'react';
import { Check, Zap, Loader2 } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
const PLAN_STYLE = {
free: { color: 'border-border', badge: null, btnClass: 'btn-ghost' },
starter: { color: 'border-blue-500/50', badge: null, btnClass: 'btn-primary' },
pro: { color: 'border-purple-500/60', badge: 'Популярный', btnClass: 'bg-purple-600 hover:bg-purple-500 text-white px-4 py-2 rounded-lg font-medium transition-colors' },
business: { color: 'border-yellow-500/40', badge: 'Для агентств', btnClass: 'bg-yellow-600 hover:bg-yellow-500 text-white px-4 py-2 rounded-lg font-medium transition-colors' },
};
const FEATURES = {
free: ['1 канал', '50 кредитов/мес', 'TG и VK публикация', 'Ручная генерация'],
starter: ['2 канала', '500 кредитов/мес', 'Автогенерация постов', 'Календарь публикаций', 'Аналитика канала'],
pro: ['5 каналов', '2000 кредитов/мес', 'Всё из Starter', 'Приоритетная генерация', 'История контента'],
business: ['Без ограничений', 'Безлимит кредитов', 'Всё из Pro', 'Поддержка 24/7', 'API доступ'],
};
export default function PlansPage() {
const [plans, setPlans] = useState([]);
const [costs, setCosts] = useState({});
const [balance, setBalance] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch('/api/billing/plans').then(r => r.json()),
fetch('/api/billing/balance').then(r => r.json()).catch(() => null),
]).then(([pd, bd]) => {
setPlans(pd.plans || []);
setCosts(Object.fromEntries((pd.costs || []).map(c => [c.operation, c.credits])));
setBalance(bd);
setLoading(false);
});
}, []);
if (loading) return (
<main className="max-w-5xl mx-auto p-6 text-center py-20">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" />
</main>
);
return (
<main className="max-w-5xl mx-auto p-4 sm:p-6">
<BackButton />
<div className="text-center mb-10">
<h1 className="text-3xl font-bold mb-2">Тарифы</h1>
<p className="text-gray-400">Выберите план под ваши задачи. Все планы включают публикацию в TG и VK.</p>
{balance && (
<p className="text-sm text-accent mt-2">
Сейчас у вас: <strong>{balance.planName}</strong> · {balance.isUnlimited ? '' : balance.credits} кредитов
</p>
)}
</div>
{/* Карточки планов */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
{plans.map(plan => {
const style = PLAN_STYLE[plan.code] || PLAN_STYLE.free;
const features = FEATURES[plan.code] || [];
const isCurrent = balance?.plan === plan.code;
const isUnlimited = plan.credits_month === -1;
return (
<div key={plan.code} className={`card p-5 flex flex-col border-2 ${style.color} ${plan.code === 'pro' ? 'relative' : ''}`}>
{style.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 text-xs px-3 py-1 rounded-full bg-purple-600 text-white font-medium whitespace-nowrap">
{style.badge}
</div>
)}
<div className="mb-4">
<div className="text-lg font-bold">{plan.name}</div>
<div className="mt-1">
{plan.price_rub === 0
? <span className="text-2xl font-bold">Бесплатно</span>
: <><span className="text-2xl font-bold">{plan.price_rub}</span><span className="text-gray-400 text-sm">/мес</span></>
}
</div>
<div className="text-sm text-accent mt-1 font-medium">
{isUnlimited ? '∞ кредитов' : `${plan.credits_month} кредитов/мес`}
</div>
</div>
<ul className="space-y-2 flex-1 mb-5">
{features.map(f => (
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
<Check className="w-4 h-4 text-green-400 mt-0.5 shrink-0" />
{f}
</li>
))}
</ul>
{isCurrent ? (
<div className="w-full text-center py-2 rounded-lg bg-surface2 text-gray-400 text-sm">Текущий план</div>
) : plan.price_rub === 0 ? (
<Link href="/register" className={`w-full text-center py-2 rounded-lg text-sm ${style.btnClass}`}>Начать бесплатно</Link>
) : (
<button className={`w-full text-center py-2 text-sm ${style.btnClass}`}
onClick={async () => {
try {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_code: plan.code }),
}).then(r => r.json());
if (res.confirmationUrl) window.location.href = res.confirmationUrl;
else alert(res.error || 'Ошибка создания платежа');
} catch { alert('Ошибка соединения'); }
}}>
Подключить
</button>
)}
</div>
);
})}
</div>
{/* Стоимость операций */}
<div className="card p-5 mb-6">
<h2 className="font-semibold mb-4 flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" /> Стоимость генерации
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
{[
{ label: 'Картинка', op: 'image', icon: '🖼', note: 'gpt-5-image-mini' },
{ label: 'Пост', op: 'text_post', icon: '✍️', note: 'aiprimetech' },
{ label: 'Статья', op: 'article', icon: '📝', note: 'Claude Sonnet' },
{ label: 'Публикация', op: 'autopublish',icon: '📤', note: 'TG / VK / MAX' },
].map(op => (
<div key={op.op} className="p-3 rounded-lg bg-surface2 text-center">
<div className="text-2xl mb-1">{op.icon}</div>
<div className="font-medium">{op.label}</div>
<div className="text-accent font-bold mt-1">
{(costs[op.op] || 0) === 0 ? 'бесплатно' : `${costs[op.op]} кр`}
</div>
<div className="text-xs text-gray-500 mt-0.5">{op.note}</div>
</div>
))}
</div>
</div>
{/* FAQ */}
<div className="card p-5">
<h2 className="font-semibold mb-4">Часто спрашивают</h2>
<div className="space-y-3 text-sm text-gray-400">
{[
['Что такое кредиты?', '1 кредит = 1 рубль. Кредиты списываются при каждой AI-генерации. Публикация постов — всегда бесплатна.'],
['Что будет если кредиты закончатся?', 'Генерация будет заблокирована до пополнения. Уже опубликованные посты и автопостинг продолжают работать.'],
['Переносятся ли кредиты на следующий месяц?', 'Нет, кредиты по тарифу сбрасываются раз в 30 дней. Дополнительно купленные кредиты не сгорают.'],
['Можно ли купить кредиты отдельно?', 'Скоро. Сейчас кредиты начисляются только по тарифному плану.'],
].map(([q, a]) => (
<details key={q} className="group">
<summary className="cursor-pointer font-medium text-gray-200 hover:text-white list-none flex items-center justify-between">
{q} <span className="text-gray-500 group-open:rotate-180 transition-transform"></span>
</summary>
<p className="mt-2 pl-1">{a}</p>
</details>
))}
</div>
</div>
</main>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Loader2, Eye, EyeOff, Sparkles } from 'lucide-react';
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [pass, setPass] = useState('');
const [pass2, setPass2] = useState('');
const [name, setName] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function submit() {
if (!email.trim() || !pass) { setError('Заполните email и пароль'); return; }
if (pass.length < 6) { setError('Пароль минимум 6 символов'); return; }
if (pass !== pass2) { setError('Пароли не совпадают'); return; }
setBusy(true); setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password: pass, name: name.trim() || undefined, mode: 'register' }),
}).then(r => r.json());
if (!res.ok) { setError(res.error || 'Ошибка'); setBusy(false); return; }
router.push(res.isNew ? '/onboarding' : '/');
} catch { setError('Ошибка соединения'); setBusy(false); }
}
return (
<main className="min-h-screen flex items-center justify-center p-4 bg-background">
{/* Background glow */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-accent/5 blur-3xl" />
</div>
<div className="w-full max-w-md relative">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 text-2xl font-bold">
<Sparkles className="w-7 h-7 text-accent" />
ZeroPost
</Link>
<p className="text-gray-400 text-sm mt-2">Создайте аккаунт это бесплатно</p>
</div>
<div className="card p-6 sm:p-8 space-y-4">
<h1 className="font-bold text-xl text-center">Регистрация</h1>
<div>
<label className="label mb-1.5">Имя <span className="text-gray-500 text-xs">(необязательно)</span></label>
<input value={name} onChange={e => setName(e.target.value)}
placeholder="Алексей"
className="input w-full" autoFocus />
</div>
<div>
<label className="label mb-1.5">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="you@example.com"
className="input w-full" />
</div>
<div>
<label className="label mb-1.5">Пароль</label>
<div className="relative">
<input type={show ? 'text' : 'password'}
value={pass} onChange={e => setPass(e.target.value)}
placeholder="Минимум 6 символов"
className="input w-full pr-10" />
<button onClick={() => setShow(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500">
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="label mb-1.5">Повторите пароль</label>
<input type="password" value={pass2}
onChange={e => setPass2(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="Ещё раз"
className="input w-full" />
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button onClick={submit} disabled={busy}
className="btn-primary w-full py-3 text-base font-medium flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-5 h-5 animate-spin" /> : <><Sparkles className="w-4 h-4" />Зарегистрироваться</>}
</button>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-gray-500">или</span>
<div className="flex-1 h-px bg-border" />
</div>
<Link href="/login" className="btn-ghost w-full py-2.5 text-center text-sm">
Уже есть аккаунт? Войти
</Link>
</div>
{/* Бонус */}
<div className="mt-4 text-center text-xs text-gray-500">
🎁 При регистрации <span className="text-accent">50 бесплатных кредитов</span>
</div>
<p className="text-center text-xs text-gray-600 mt-3">
Регистрируясь, вы принимаете{' '}
<Link href="/terms" className="hover:text-gray-400">условия использования</Link>
</p>
</div>
</main>
);
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, TrendingUp, Zap, Image as Img, RefreshCw } from 'lucide-react';
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'alltime', label: 'Всё время' },
];
const PROVIDER_LABELS = {
'aiprimetech': 'aiprimetech.io',
'routerai': 'routerai.ru',
'nyxos': 'Nyxos Plus',
'aiguoguo': 'aiguoguo',
};
const TYPE_LABELS = {
'chat': '💬 Текст',
'image': '🖼 Изображение',
'image_via_responses': '🖼 Изображение (responses)',
'article': '📝 Статья',
'topic': '🔍 Топики',
};
function fmt(n) { return Number(n || 0).toFixed(2); }
function fmtInt(n) { return Number(n || 0).toLocaleString('ru-RU'); }
export default function SpendingPage() {
const [period, setPeriod] = useState('month');
const [data, setData] = useState(null);
const [byProvider, setByProvider] = useState(null);
const [loading, setLoading] = useState(true);
async function load(p) {
setLoading(true);
try {
const [r1, r2] = await Promise.all([
fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()),
]);
setData(r1);
setByProvider(r2);
} catch {}
setLoading(false);
}
useEffect(() => { load(period); }, [period]);
const totals = data?.totals || {};
// Расходы по ключевым провайдерам
const aiprimetech = byProvider?.breakdown?.find(b => b.key === 'aiprimetech');
const routerai = byProvider?.breakdown?.find(b => b.key === 'routerai');
const nyxos = byProvider?.breakdown?.find(b => b.key === 'nyxos' || b.key?.includes('nyxos'));
return (
<main className="max-w-5xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" /> Расходы на AI
</h1>
<p className="text-sm text-gray-500 mt-0.5">Только aiprimetech.io и routerai.ru</p>
</div>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => setPeriod(p.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (
<>
{/* Итого */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Итого, ₽', value: `${fmt(totals.cost_rub)}`, accent: true },
{ label: 'Запросов', value: fmtInt(totals.calls) },
{ label: 'Токенов', value: fmtInt((totals.prompt_tokens||0) + (totals.completion_tokens||0)) },
{ label: 'Картинок', value: fmtInt(totals.image_count) },
].map(s => (
<div key={s.label} className={`card p-4 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По провайдерам</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{[
{ key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текстовая генерация', data: aiprimetech },
{ key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Генерация изображений', data: routerai },
].map(p => {
const d = p.data;
return (
<div key={p.key} className="card p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{p.icon}</span>
<div>
<div className="font-semibold text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-right">
<div className="text-lg font-bold text-accent"> {fmt(d?.cost_rub)}</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtInt(d?.calls)}</div>запросов</div>
<div><div className="font-medium text-gray-200">{d?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtInt(d?.image_count||((d?.prompt_tokens||0)+(d?.completion_tokens||0)))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
);
})}
</div>
{/* Разбивка по типу запроса */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По типу операции</h2>
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Токены / Картинки</th>
<th className="px-4 py-2.5 text-right">Стоимость, </th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtInt(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0
? `${row.image_count} шт.`
: fmtInt((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
{(!data.breakdown?.length) && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-500 text-sm">Нет данных за период</td></tr>
)}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold text-sm">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtInt(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import Header from '@/components/Header';
import AdminPanel from '@/components/AdminPanel';
export const dynamic = 'force-dynamic';
export default async function SystemPage({ searchParams }) {
const user = await requireUser();
if (!user) redirect('/login');
if (!user.isAdmin) redirect('/');
return (
<>
<Header user={user} />
<AdminPanel initialSection={searchParams?.section || 'dashboard'} />
</>
);
}
+670
View File
@@ -0,0 +1,670 @@
'use client';
import { useState, useEffect } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue';
import AdminLogs from './admin/AdminLogs';
import AdminAutogen from './admin/AdminAutogen';
import AdminContent from './admin/AdminContent';
import AdminTopicBank from './admin/AdminTopicBank';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
// ──────────────────────────────────────────────────────────────
const SECTIONS = [
{ id: 'dashboard', label: 'Сводка', icon: BarChart3, desc: 'Пользователи, посты, финансы' },
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
{ id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' },
{ id: 'smtp', label: 'Email / SMTP', icon: Mail, desc: 'Уведомления пользователям' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
];
export default function AdminPanel({ initialSection = 'settings' }) {
const [section, setSection] = useState(initialSection);
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6 text-sm text-gray-500">
<Link href="/" className="hover:text-gray-200 flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Главная
</Link>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-gray-200">Администрирование</span>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-accent">{SECTIONS.find(s => s.id === section)?.label}</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<aside className="w-52 shrink-0">
<div className="card p-2 space-y-0.5 sticky top-6">
<p className="text-xs text-gray-500 uppercase tracking-wide px-3 py-2 font-medium">Разделы</p>
{SECTIONS.map(({ id, label, icon: Icon, desc }) => (
<button key={id} onClick={() => setSection(id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center gap-3 ${
section === id
? 'bg-accent/10 text-accent'
: 'hover:bg-surface2 text-gray-300'
}`}>
<Icon className="w-4 h-4 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{label}</div>
<div className="text-xs text-gray-500 truncate">{desc}</div>
</div>
</button>
))}
</div>
</aside>
{/* Content */}
<div className="flex-1 min-w-0">
{section === 'dashboard' && <DashboardSection />}
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
{section === 'engine' && <SettingsSection categories={['engine']} />}
{section === 'payments' && <SettingsSection categories={['payments']} />}
{section === 'spending' && <SpendingSection />}
{section === 'queue' && <AdminQueue />}
{section === 'logs' && <AdminLogs />}
{section === 'autogen' && <AdminAutogen />}
{section === 'content' && <AdminContent />}
{section === 'topicbank' && <AdminTopicBank />}
{section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />}
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Settings section (API keys)
// ──────────────────────────────────────────────────────────────
const CATEGORY_META = {
ai_providers: {
title: 'AI-провайдеры',
hint: 'Ключи и URL для текстовой и картиночной генерации. Меняются на лету.',
},
photo_search: {
title: 'Поиск фото',
hint: 'Yandex Search API: ключ и folder.',
},
payments: {
title: 'ЮKassa',
hint: 'Shop ID и Secret Key из личного кабинета. Webhook: https://engine.zeropost.ru/api/billing/webhook',
},
};
function SettingsSection({ categories }) {
const [data, setData] = useState({});
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
const all = {};
for (const cat of categories) {
try {
const rows = await fetch(`/api/admin/settings?category=${cat}`).then(r => r.json());
all[cat] = Array.isArray(rows) ? rows : [];
} catch { all[cat] = []; }
}
setData(all);
setLoaded(true);
setLoading(false);
}
useEffect(() => { load(); }, []);
return (
<div className="space-y-6">
{categories.map(cat => {
const meta = CATEGORY_META[cat] || { title: cat, hint: '' };
const rows = data[cat] || [];
return (
<section key={cat} className="card p-5">
<div className="mb-4">
<h2 className="font-semibold">{meta.title}</h2>
{meta.hint && <p className="text-xs text-gray-500 mt-1">{meta.hint}</p>}
</div>
{loading && !rows.length
? <div className="py-4 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto" /></div>
: rows.map(row => (
<SettingRow key={row.key} row={row} onSaved={load} />
))
}
</section>
);
})}
</div>
);
}
function SettingRow({ row, onSaved }) {
const [val, setVal] = useState(row.value || '');
const [show, setShow] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState(null); // 'ok' | 'error'
const [errMsg, setErrMsg] = useState('');
const dirty = val !== (row.value || '');
async function save() {
setSaving(true); setStatus(null);
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: val }),
}).then(r => r.json());
if (res.error) { setStatus('error'); setErrMsg(res.error); }
else { setStatus('ok'); onSaved(); setTimeout(() => setStatus(null), 2000); }
} catch (e) { setStatus('error'); setErrMsg(e.message); }
setSaving(false);
}
const isSecret = row.is_secret;
const inputType = isSecret && !show ? 'password' : 'text';
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<label className="label text-xs">{row.key}</label>
<span className="text-xs text-gray-600">{row.category}</span>
</div>
{row.description && <p className="text-xs text-gray-500 mb-1.5">{row.description}</p>}
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={inputType}
value={val}
onChange={e => setVal(e.target.value)}
className="input w-full pr-8 font-mono text-sm"
placeholder={isSecret ? '••••••••' : 'Введите значение...'}
/>
{isSecret && (
<button onClick={() => setShow(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
{dirty && (
<button onClick={save} disabled={saving}
className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{status === 'ok' && <Check className="w-5 h-5 text-green-400 self-center shrink-0" />}
{status === 'error' && <AlertCircle className="w-5 h-5 text-red-400 self-center shrink-0" title={errMsg} />}
</div>
<div className="text-xs text-gray-600 mt-1">
обновлено: {row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Spending section
// ──────────────────────────────────────────────────────────────
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'alltime', label: 'Всё время' },
];
const TYPE_LABELS = {
'chat': '💬 Текст',
'image': '🖼 Изображение',
'image_via_responses': '🖼 Изображение',
'article': '📝 Статья',
'topic': '🔍 Топики',
};
function fmt(n) { return Number(n || 0).toFixed(2); }
function fmtI(n) { return Number(n || 0).toLocaleString('ru-RU'); }
function SpendingSection() {
const [period, setPeriod] = useState('month');
const [data, setData] = useState(null);
const [byProv, setByProv] = useState(null);
const [loading, setLoading] = useState(false);
async function load(p) {
setLoading(true);
try {
const [r1, r2] = await Promise.all([
fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()),
]);
setData(r1); setByProv(r2);
} catch {}
setLoading(false);
}
useEffect(() => { load(period); }, []);
const totals = data?.totals || {};
const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
const routerai = byProv?.breakdown?.find(b => b.key === 'routerai');
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Расходы на AI</h2>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => { setPeriod(p.v); load(p.v); }}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (<>
{/* Итого */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Итого, ₽', value: `${fmt(totals.cost_rub)}`, accent: true },
{ label: 'Запросов', value: fmtI(totals.calls) },
{ label: 'Токенов', value: fmtI((totals.prompt_tokens||0)+(totals.completion_tokens||0)) },
{ label: 'Картинок', value: fmtI(totals.image_count) },
].map(s => (
<div key={s.label} className={`card p-3 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текст', data: aiprimetech },
{ key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Картинки', data: routerai },
].map(p => (
<div key={p.key} className="card p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{p.icon}</span>
<div>
<div className="font-medium text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-accent font-bold"> {fmt(p.data?.cost_rub)}</div>
</div>
<div className="grid grid-cols-3 gap-1 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtI(p.data?.calls)}</div>запросов</div>
<div><div className="font-medium text-red-400">{p.data?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtI(p.data?.image_count || (p.data?.prompt_tokens||0)+(p.data?.completion_tokens||0))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
))}
</div>
{/* Таблица по операциям */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Объём</th>
<th className="px-4 py-2.5 text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtI(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0 ? `${row.image_count} шт.` : fmtI((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtI(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>)}
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Plans & Credits section
// ──────────────────────────────────────────────────────────────
function PlansSection() {
const [plans, setPlans] = useState([]);
const [costs, setCosts] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState({});
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const res = await fetch('/api/billing/plans').then(r => r.json());
setPlans(res.plans || []);
setCosts(res.costs || []);
} catch {}
setLoading(false);
}
async function savePlan(plan) {
setSaving(s => ({ ...s, [plan.id]: true }));
try {
await fetch(`/api/admin/plans/${plan.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price_rub: plan.price_rub, credits_month: plan.credits_month, channels_max: plan.channels_max }),
});
setMsg('Сохранено ✓');
setTimeout(() => setMsg(''), 2000);
} catch {}
setSaving(s => ({ ...s, [plan.id]: false }));
}
async function saveCost(cost) {
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: true }));
try {
await fetch(`/api/admin/credit-costs/${cost.operation}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credits: cost.credits }),
});
setMsg('Сохранено ✓');
setTimeout(() => setMsg(''), 2000);
} catch {}
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: false }));
}
useEffect(() => { load(); }, []);
const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
return (
<div className="space-y-6">
{msg && <div className="text-sm text-green-400 flex items-center gap-1"><Check className="w-4 h-4" />{msg}</div>}
{/* Тарифные планы */}
<section className="card p-5">
<h2 className="font-semibold mb-1">Тарифные планы</h2>
<p className="text-xs text-gray-500 mb-4">Цены и лимиты. -1 = безлимит.</p>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
<div className="space-y-3">
{plans.map(plan => {
const [p, setP] = [plan, (updates) => setPlans(pp => pp.map(x => x.id === plan.id ? { ...x, ...updates } : x))];
return (
<div key={p.id} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
<div className="w-20 font-medium text-sm">{PLAN_LABELS[p.code] || p.code}</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">/мес:</span>
<input type="number" value={p.price_rub} onChange={e => setP({ price_rub: +e.target.value })}
className="input w-20 text-sm py-1" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">кредитов:</span>
<input type="number" value={p.credits_month} onChange={e => setP({ credits_month: +e.target.value })}
className="input w-20 text-sm py-1" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">каналов:</span>
<input type="number" value={p.channels_max} onChange={e => setP({ channels_max: +e.target.value })}
className="input w-16 text-sm py-1" />
</div>
<button onClick={() => savePlan(p)} disabled={saving[p.id]}
className="btn-primary text-xs px-2.5 py-1.5 ml-auto flex items-center gap-1">
{saving[p.id] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Сохранить
</button>
</div>
);
})}
</div>
)}
</section>
{/* Стоимость операций */}
<section className="card p-5">
<h2 className="font-semibold mb-1">Стоимость операций (кредиты)</h2>
<p className="text-xs text-gray-500 mb-4">Сколько кредитов списывается за каждую операцию.</p>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
<div className="space-y-2">
{costs.map(cost => {
const [c, setC] = [cost, (updates) => setCosts(cc => cc.map(x => x.operation === cost.operation ? { ...x, ...updates } : x))];
const icons = { image: '🖼', text_post: '✍️', article: '📝', autopublish: '📤' };
return (
<div key={c.operation} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
<span className="text-lg w-7 text-center">{icons[c.operation] || '⚙️'}</span>
<div className="flex-1 text-sm">{c.description || c.operation}</div>
<div className="flex items-center gap-2">
<input type="number" value={c.credits} onChange={e => setC({ credits: +e.target.value })}
className="input w-16 text-sm py-1 text-center" min={0} />
<span className="text-xs text-gray-500">кр</span>
</div>
<button onClick={() => saveCost(c)} disabled={saving[`cost_${c.operation}`]}
className="btn-primary text-xs px-2.5 py-1.5 flex items-center gap-1">
{saving[`cost_${c.operation}`] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Сохранить
</button>
</div>
);
})}
</div>
)}
</section>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Dashboard section
// ──────────────────────────────────────────────────────────────
function DashboardSection() {
const [data, setData] = useState(null);
const [loading, setLoading]= useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/dashboard').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
function Stat({ label, value, sub, accent }) {
return (
<div className={`card p-4 ${accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${accent ? 'text-accent' : ''}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-1">{sub}</div>}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">Сводка</h2>
<button onClick={load} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Пользователи */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Пользователи</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего пользователей" value={data.users.total} accent />
<Stat label="Новых за 7 дней" value={data.users.new_7d} />
<Stat label="Новых за 30 дней" value={data.users.new_30d} />
</div>
</div>
{/* Каналы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Каналы</p>
<div className="grid grid-cols-4 gap-3">
<Stat label="Всего каналов"
value={data.channels.reduce((s, c) => s + c.cnt, 0)} />
{data.channels.map(c => (
<Stat key={c.platform}
label={`${PLATFORM_ICONS[c.platform] || '📢'} ${c.platform}`}
value={c.cnt} />
))}
</div>
</div>
{/* Посты */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Публикации</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего опубликовано" value={data.posts.total} />
<Stat label="За последние 24 часа" value={data.posts.today} />
<Stat label="За 7 дней" value={data.posts.week} />
</div>
</div>
{/* Финансы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Финансы</p>
<div className="grid grid-cols-2 gap-3">
<div className="card p-4 border-green-500/30 bg-green-500/5">
<div className="text-2xl font-bold text-green-400">{data.revenue.month_rub.toLocaleString('ru-RU')}</div>
<div className="text-xs text-gray-400 mt-0.5">Выручка за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">{data.revenue.paid_count} платежей итого {data.revenue.total_rub.toLocaleString('ru-RU')}</div>
</div>
<div className="card p-4 border-red-500/20 bg-red-500/5">
<div className="text-2xl font-bold text-red-400">{data.ai.cost_rub.toFixed(2)}</div>
<div className="text-xs text-gray-400 mt-0.5">Расходы на AI за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">
{data.ai.calls} запросов {data.ai.errors} ошибок
</div>
</div>
</div>
</div>
{/* Черновики */}
{data.drafts.pending > 0 && (
<div className="card p-4 border-accent/30 bg-accent/5 flex items-center justify-between">
<div>
<div className="font-medium text-sm"> {data.drafts.pending} черновиков ждут одобрения</div>
<div className="text-xs text-gray-400 mt-0.5">Просмотрите и запланируйте публикацию</div>
</div>
<a href="/drafts" className="btn-primary text-sm px-3 py-1.5">Смотреть </a>
</div>
)}
{/* Регистрации 14 дней */}
{data.registrations_14d.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Регистрации последние 14 дней</p>
<div className="card p-4">
<div className="flex items-end gap-1 h-16">
{(() => {
const max = Math.max(...data.registrations_14d.map(r => r.cnt), 1);
// Заполняем пустые дни
const days = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(); d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0];
const found = data.registrations_14d.find(r => r.day === key);
days.push({ day: key, cnt: found?.cnt || 0 });
}
return days.map((r, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full bg-accent/20 rounded-sm transition-all"
style={{ height: `${Math.max(2, (r.cnt / max) * 56)}px` }}
title={`${r.day}: ${r.cnt}`} />
{i % 7 === 6 && <div className="text-xs text-gray-600 whitespace-nowrap">
{new Date(r.day).toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}
</div>}
</div>
));
})()}
</div>
</div>
</div>
)}
</>)}
</div>
);
}
// ── SMTP Test Button ──────────────────────────────────────────
function SmtpTestButton() {
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
async function test() {
if (!email.trim()) return;
setBusy(true);
const res = await fetch('/api/admin/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email }),
}).then(r => r.json());
setBusy(false);
setMsg(res.ok ? '✅ Письмо отправлено' : '❌ ' + (res.error || res.message));
setTimeout(() => setMsg(''), 5000);
}
return (
<div className="card p-4 border-accent/20 bg-accent/5">
<h3 className="font-medium text-sm mb-3">Тест отправки</h3>
<div className="flex gap-2">
<input value={email} onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && test()}
type="email" placeholder="test@example.com"
className="input flex-1 text-sm py-1.5" />
<button onClick={test} disabled={busy || !email.trim()}
className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mail className="w-3.5 h-3.5" />}
Отправить тест
</button>
</div>
{msg && <p className="text-xs mt-2">{msg}</p>}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
export default function BackButton({ href, label = 'Назад' }) {
const router = useRouter();
function go() {
if (href) router.push(href);
else if (window.history.length > 1) router.back();
else router.push('/');
}
return (
<button onClick={go} className="btn-ghost flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-200 mb-4 -ml-1">
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
+566
View File
@@ -0,0 +1,566 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Calendar, LayoutGrid, List,
RefreshCw, Filter, Clock, CheckCircle2, XCircle,
FileText, AlertCircle } from 'lucide-react';
// ── Константы ─────────────────────────────────────────────────────────────────
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь',
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const STATUS_META = {
draft: { label: 'Черновик', color: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30', dot: 'bg-zinc-400' },
scheduled: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
published: { label: 'Опубликован', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', dot: 'bg-emerald-400' },
failed: { label: 'Ошибка', color: 'bg-red-500/15 text-red-400 border-red-500/30', dot: 'bg-red-400' },
pending: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
};
const SOURCE_LABEL = { user_post: 'Пост', scheduled_post: 'Авто' };
// ── Утилиты ───────────────────────────────────────────────────────────────────
function startOfWeek(date) {
const d = new Date(date);
const day = d.getDay(); // 0=вс
const diff = day === 0 ? -6 : 1 - day; // делаем пн первым
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(date, n) {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
function isoDay(date) {
// Ключ локального дня (НЕ UTC) — иначе в МСК события смещаются на день
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// ── EventCard ────────────────────────────────────────────────────────────────
function EventCard({ event, compact = false, draggable = false, onDragStart }) {
const meta = STATUS_META[event.status] || STATUS_META.draft;
return (
<div
draggable={draggable && event.editable}
onDragStart={draggable ? (e) => onDragStart(e, event) : undefined}
className={`
group rounded-md border px-2 py-1 text-xs cursor-default select-none
transition-opacity hover:opacity-90
${meta.color}
${draggable && event.editable ? 'cursor-grab active:cursor-grabbing' : ''}
${compact ? 'truncate' : ''}
`}
title={event.title || event.preview || ''}
>
<div className="flex items-center gap-1 min-w-0">
<span className={`shrink-0 w-1.5 h-1.5 rounded-full ${meta.dot}`} />
{event.scheduled_at && (
<span className="shrink-0 opacity-70">{formatTime(event.scheduled_at)}</span>
)}
<span className="truncate font-medium">
{event.title || event.preview?.slice(0, 60) || '—'}
</span>
{!compact && (
<span className="shrink-0 ml-auto opacity-50 text-[10px]">
{SOURCE_LABEL[event.source] || ''}
</span>
)}
</div>
</div>
);
}
// ── DayCell ───────────────────────────────────────────────────────────────────
function DayCell({ date, events, isToday, isCurrentMonth, onDrop, onDragOver, onDragStart }) {
const MAX_VISIBLE = 3;
const visible = events.slice(0, MAX_VISIBLE);
const hidden = events.length - MAX_VISIBLE;
return (
<div
className={`
min-h-[90px] p-1.5 rounded-lg border transition-colors
${isCurrentMonth ? 'bg-surface border-border' : 'bg-transparent border-transparent opacity-40'}
${isToday ? 'ring-1 ring-accent/60' : ''}
`}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, date)}
>
<div className={`text-xs font-semibold mb-1 w-6 h-6 flex items-center justify-center rounded-full
${isToday ? 'bg-accent text-white' : 'text-text-soft'}`}>
{date.getDate()}
</div>
<div className="flex flex-col gap-0.5">
{visible.map(ev => (
<EventCard key={ev.id} event={ev} compact draggable onDragStart={onDragStart} />
))}
{hidden > 0 && (
<div className="text-[10px] text-text-mute pl-1">+{hidden} ещё</div>
)}
</div>
</div>
);
}
// ── MonthGrid ─────────────────────────────────────────────────────────────────
function MonthGrid({ year, month, eventsByDay, onDrop, onDragOver, onDragStart }) {
const today = new Date();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Заполняем сетку: пн первый день недели
const startPad = ((firstDay.getDay() + 6) % 7); // смещение от пн
const totalCells = Math.ceil((startPad + lastDay.getDate()) / 7) * 7;
const cells = [];
for (let i = 0; i < totalCells; i++) {
const d = new Date(year, month, 1 - startPad + i);
cells.push(d);
}
return (
<div>
<div className="grid grid-cols-7 mb-1">
{WEEKDAYS.map(w => (
<div key={w} className="text-center text-xs font-medium text-text-mute py-1">{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => (
<DayCell
key={i}
date={d}
events={eventsByDay[isoDay(d)] || []}
isToday={sameDay(d, today)}
isCurrentMonth={d.getMonth() === month}
onDrop={onDrop}
onDragOver={onDragOver}
onDragStart={onDragStart}
/>
))}
</div>
</div>
);
}
// ── WeekGrid ──────────────────────────────────────────────────────────────────
function WeekGrid({ weekStart, eventsByDay, onDrop, onDragOver, onDragStart }) {
const today = new Date();
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
return (
<div className="grid grid-cols-7 gap-2">
{days.map((d, i) => {
const dayEvents = eventsByDay[isoDay(d)] || [];
return (
<div key={i}
className={`min-h-[200px] rounded-xl border p-2
${sameDay(d, today) ? 'ring-1 ring-accent/60 border-accent/30' : 'border-border'}
bg-surface`}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, d)}
>
<div className={`text-xs font-semibold mb-2 text-center w-7 h-7 mx-auto
flex items-center justify-center rounded-full
${sameDay(d, today) ? 'bg-accent text-white' : 'text-text-soft'}`}>
{d.getDate()}
</div>
<div className="text-[10px] text-text-mute text-center mb-2">
{WEEKDAYS[i]}
</div>
<div className="flex flex-col gap-1">
{dayEvents.map(ev => (
<EventCard key={ev.id} event={ev} draggable onDragStart={onDragStart} />
))}
</div>
</div>
);
})}
</div>
);
}
// ── ListView ──────────────────────────────────────────────────────────────────
function ListView({ events }) {
if (!events.length) {
return (
<div className="card p-12 text-center text-text-mute">
<Calendar className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Нет событий в выбранном периоде</p>
</div>
);
}
// Группируем по дням
const groups = {};
for (const ev of events) {
const key = isoDay(new Date(ev.date));
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
}
return (
<div className="flex flex-col gap-4">
{Object.entries(groups).sort().map(([day, evs]) => {
const d = new Date(day);
const today = new Date();
const isToday = sameDay(d, today);
return (
<div key={day}>
<div className={`text-sm font-semibold mb-2 ${isToday ? 'text-accent' : 'text-text-soft'}`}>
{isToday ? 'Сегодня' : d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
<div className="flex flex-col gap-2">
{evs.map(ev => {
const meta = STATUS_META[ev.status] || STATUS_META.draft;
return (
<div key={ev.id} className="card p-3 flex items-start gap-3">
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${meta.dot}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm truncate">
{ev.title || ev.preview?.slice(0, 80) || '—'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full border ${meta.color}`}>
{meta.label}
</span>
<span className="text-xs text-text-mute">
{SOURCE_LABEL[ev.source]}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-text-mute">
<span>{ev.channel_name}</span>
{ev.scheduled_at && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(ev.scheduled_at)}
</span>
)}
{ev.error && (
<span className="text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{ev.error.slice(0, 60)}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// ── Legend ────────────────────────────────────────────────────────────────────
function Legend() {
const items = [
{ status: 'draft', Icon: FileText },
{ status: 'scheduled', Icon: Clock },
{ status: 'published', Icon: CheckCircle2 },
{ status: 'failed', Icon: XCircle },
];
return (
<div className="flex items-center gap-3 flex-wrap">
{items.map(({ status, Icon }) => {
const m = STATUS_META[status];
return (
<div key={status} className="flex items-center gap-1.5 text-xs text-text-soft">
<span className={`w-2 h-2 rounded-full ${m.dot}`} />
{m.label}
</div>
);
})}
</div>
);
}
// ── Главный компонент ─────────────────────────────────────────────────────────
export default function CalendarView({ channels }) {
const today = new Date();
const [view, setView] = useState('month'); // month | week | list
const [currentDate, setCurrentDate] = useState(today);
const [channelFilter, setChannelFilter] = useState('');
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const dragRef = useRef(null); // перетаскиваемое событие
// Вычисляем диапазон для запроса
const getRange = useCallback(() => {
if (view === 'week') {
const ws = startOfWeek(currentDate);
return {
from: isoDay(ws),
to: isoDay(addDays(ws, 6)),
};
}
// month и list — показываем текущий + соседние
return {
from: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)),
to: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)),
};
}, [view, currentDate]);
const fetchEvents = useCallback(async () => {
setLoading(true);
setError('');
try {
const range = getRange();
const qs = new URLSearchParams(range);
if (channelFilter) qs.set('channel_id', channelFilter);
const res = await fetch(`/api/calendar?${qs}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
setEvents(data.events || []);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [getRange, channelFilter]);
useEffect(() => { fetchEvents(); }, [fetchEvents]);
// Группировка по дням для grid-видов
const eventsByDay = {};
for (const ev of events) {
const key = isoDay(new Date(ev.date));
if (!eventsByDay[key]) eventsByDay[key] = [];
eventsByDay[key].push(ev);
}
// ── Навигация ──────────────────────────────────────────────────────────────
function navigate(dir) {
const d = new Date(currentDate);
if (view === 'week') {
d.setDate(d.getDate() + dir * 7);
} else {
d.setMonth(d.getMonth() + dir);
}
setCurrentDate(d);
}
function goToday() { setCurrentDate(new Date()); }
function headerLabel() {
if (view === 'week') {
const ws = startOfWeek(currentDate);
const we = addDays(ws, 6);
return `${ws.getDate()}${we.getDate()} ${MONTHS[we.getMonth()]} ${we.getFullYear()}`;
}
return `${MONTHS[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
}
// ── Drag & drop ────────────────────────────────────────────────────────────
function handleDragStart(e, event) {
dragRef.current = event;
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
async function handleDrop(e, targetDate) {
e.preventDefault();
const ev = dragRef.current;
dragRef.current = null;
if (!ev) return;
if (!ev.editable || ev.source !== 'user_post') return;
// Берём время из оригинального scheduled_at, меняем только дату
const orig = ev.scheduled_at ? new Date(ev.scheduled_at) : new Date();
const newDt = new Date(targetDate);
newDt.setHours(orig.getHours(), orig.getMinutes(), 0, 0);
// Оптимистичное обновление
setEvents(prev => prev.map(e2 =>
e2.id === ev.id ? { ...e2, scheduled_at: newDt.toISOString(), date: newDt.toISOString() } : e2
));
try {
const res = await fetch('/api/calendar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ev.source_id, scheduled_at: newDt.toISOString() }),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || 'Ошибка переноса');
}
} catch (err) {
alert(`Не удалось перенести: ${err.message}`);
fetchEvents(); // откатываем
}
}
// ── Рендер ────────────────────────────────────────────────────────────────
const viewBtns = [
{ id: 'month', Icon: LayoutGrid, label: 'Месяц' },
{ id: 'week', Icon: Calendar, label: 'Неделя' },
{ id: 'list', Icon: List, label: 'Список' },
];
return (
<div className="flex flex-col gap-4">
{/* ── Тулбар ── */}
<div className="flex flex-wrap items-center gap-3">
{/* Навигация */}
<div className="flex items-center gap-1">
<button onClick={() => navigate(-1)} className="btn-ghost p-2 rounded-lg">
<ChevronLeft className="w-4 h-4" />
</button>
<button onClick={goToday} className="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium">
Сегодня
</button>
<button onClick={() => navigate(1)} className="btn-ghost p-2 rounded-lg">
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Заголовок периода */}
<h2 className="text-lg font-semibold flex-1 min-w-0">{headerLabel()}</h2>
{/* Фильтр канала */}
{channels.length > 0 && (
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-text-mute" />
<select
className="input py-1.5 text-sm w-40"
value={channelFilter}
onChange={e => setChannelFilter(e.target.value)}
>
<option value="">Все каналы</option>
{channels.map(ch => (
<option key={ch.id} value={ch.id}>{ch.name}</option>
))}
</select>
</div>
)}
{/* Переключатель вида */}
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border">
{viewBtns.map(({ id, Icon, label }) => (
<button
key={id}
onClick={() => setView(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${view === id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}
>
<Icon className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{/* Обновить */}
<button onClick={fetchEvents} className="btn-ghost p-2 rounded-lg" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Легенда */}
<div className="flex items-center justify-between flex-wrap gap-2">
<Legend />
{events.length > 0 && (
<span className="text-xs text-text-mute">{events.length} событий</span>
)}
</div>
{/* Ошибка */}
{error && (
<div className="card p-3 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Скелетон загрузки */}
{loading && events.length === 0 && (
<div className="card p-8 text-center text-text-mute animate-pulse">
Загружаю события
</div>
)}
{/* Контент */}
{!loading || events.length > 0 ? (
<>
{view === 'month' && (
<MonthGrid
year={currentDate.getFullYear()}
month={currentDate.getMonth()}
eventsByDay={eventsByDay}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
/>
)}
{view === 'week' && (
<WeekGrid
weekStart={startOfWeek(currentDate)}
eventsByDay={eventsByDay}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
/>
)}
{view === 'list' && <ListView events={events} />}
</>
) : null}
{/* Подсказка про drag & drop */}
{(view === 'month' || view === 'week') && (
<p className="text-xs text-text-mute text-center">
Перетащи карточку пользовательского поста на другую дату, чтобы перенести его
</p>
)}
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
'use client';
/**
* ChannelAnalytics — вкладка аналитики в ChannelView.
* Props: channelId, channelName
*/
import { useState, useEffect, useCallback } from 'react';
import { BarChart2, TrendingUp, Clock, RefreshCw,
Heart, Share2, Eye, AlertCircle, Calendar } from 'lucide-react';
const DOW_SHORT = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
const BAR_HEIGHT = 80;
// ── Мини-бар для гистограммы ──────────────────────────────────────────────────
function Bar({ value, max, label, highlight }) {
const pct = max > 0 ? (value / max) : 0;
return (
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-[10px] text-text-mute">{value > 0 ? value : ''}</span>
<div className="w-full rounded-t-sm" style={{
height: BAR_HEIGHT,
display: 'flex',
alignItems: 'flex-end',
}}>
<div
className={`w-full rounded-t-sm transition-all ${highlight ? 'bg-accent' : 'bg-accent/30'}`}
style={{ height: `${Math.max(pct * BAR_HEIGHT, value > 0 ? 4 : 0)}px` }}
/>
</div>
<span className="text-[10px] text-text-mute">{label}</span>
</div>
);
}
// ── Карточка метрики ──────────────────────────────────────────────────────────
function MetricCard({ Icon, label, value, sub, color = 'text-accent' }) {
return (
<div className="card p-4 flex items-center gap-3">
<div className={`p-2 rounded-lg bg-surface2 ${color}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<div className="text-xl font-bold">{value ?? '—'}</div>
<div className="text-xs text-text-mute">{label}</div>
{sub && <div className="text-xs text-text-mute mt-0.5">{sub}</div>}
</div>
</div>
);
}
// ── Строка реакций ────────────────────────────────────────────────────────────
function ReactionBar({ reactions }) {
if (!reactions || !Object.keys(reactions).length) return null;
const total = Object.values(reactions).reduce((s, v) => s + v, 0);
return (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(reactions)
.sort((a, b) => b[1] - a[1])
.map(([emoji, count]) => (
<span key={emoji} className="flex items-center gap-1 text-sm bg-surface2 px-2 py-0.5 rounded-full border border-border">
{emoji} <span className="text-xs text-text-mute">{count}</span>
</span>
))}
<span className="text-xs text-text-mute ml-auto">всего {total}</span>
</div>
);
}
// ── Главный компонент ─────────────────────────────────────────────────────────
export default function ChannelAnalytics({ channelId, channelName }) {
const [metrics, setMetrics] = useState(null);
const [bestTime, setBestTime] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [days, setDays] = useState(30);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [m, b] = await Promise.all([
fetch(`/api/metrics/channel/${channelId}?days=${days}`).then(r => r.json()),
fetch(`/api/metrics/best-time/${channelId}?days=90`).then(r => r.json()),
]);
if (m.error) throw new Error(m.error);
setMetrics(m);
setBestTime(b);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [channelId, days]);
useEffect(() => { load(); }, [load]);
const totals = metrics?.totals || {};
const topPosts = metrics?.top_posts || [];
const reactionTotals = metrics?.reaction_totals || [];
const totalReactions = reactionTotals.reduce((s, r) => s + parseInt(r.total), 0);
// Лучший день недели по кол-ву публикаций
const dowData = Array.from({ length: 7 }, (_, i) => {
const d = bestTime?.by_dow?.find(r => parseInt(r.dow) === i + 1);
return { label: DOW_SHORT[i], value: d?.count || 0 };
});
const maxDow = Math.max(...dowData.map(d => d.value), 1);
const bestDow = dowData.reduce((best, d, i) => d.value > best.value ? { ...d, i } : best, { value: 0, i: -1 });
// Лучший час
const hourData = Array.from({ length: 24 }, (_, h) => {
const d = bestTime?.by_hour?.find(r => parseInt(r.hour) === h);
return { label: `${h}`, value: d?.count || 0 };
});
const maxHour = Math.max(...hourData.map(d => d.value), 1);
const bestHour = hourData.reduce((best, d, i) => d.value > best.value ? { ...d, i } : best, { value: 0, i: -1 });
return (
<div className="flex flex-col gap-5">
{/* Тулбар */}
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="font-semibold flex items-center gap-2">
<BarChart2 className="w-4 h-4 text-accent" />
Аналитика
</h3>
<div className="flex items-center gap-2">
<select
className="input py-1 text-sm w-32"
value={days}
onChange={e => setDays(parseInt(e.target.value))}
>
<option value={7}>7 дней</option>
<option value={30}>30 дней</option>
<option value={90}>90 дней</option>
</select>
<button onClick={load} className="btn-ghost p-1.5 rounded-lg" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{error && (
<div className="card p-3 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" /> {error}
</div>
)}
{/* Карточки-цифры */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<MetricCard Icon={Calendar} label={`Постов за ${days} дн.`} value={totals.total_posts || 0} />
<MetricCard Icon={Eye} label="Опубликовано в TG" value={totals.published_to_tg || 0} />
<MetricCard Icon={Heart} label="Реакций за период" value={totalReactions || 0} color="text-pink-400" />
<MetricCard Icon={Share2} label="Пересылок" value={totals.total_forwards || 0} color="text-blue-400" />
</div>
{/* Топ реакций */}
{reactionTotals.length > 0 && (
<div className="card p-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-400" /> Реакции за {days} дней
</div>
<div className="flex items-center gap-2 flex-wrap">
{reactionTotals.map(r => (
<span key={r.emoji} className="flex items-center gap-1.5 text-sm bg-surface2 px-3 py-1 rounded-full border border-border">
{r.emoji}
<span className="font-semibold">{r.total}</span>
</span>
))}
</div>
</div>
)}
{/* Лучший день недели */}
<div className="card p-4">
<div className="text-sm font-semibold mb-1 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-accent" /> Активность по дням недели
</div>
{bestDow.value > 0 && (
<p className="text-xs text-text-mute mb-3">
Чаще всего публикуешь в <span className="text-accent font-medium">{DOW_SHORT[bestDow.i]}</span>
</p>
)}
<div className="flex gap-1 items-end" style={{ height: BAR_HEIGHT + 32 }}>
{dowData.map((d, i) => (
<Bar key={i} value={d.value} max={maxDow} label={d.label} highlight={i === bestDow.i} />
))}
</div>
</div>
{/* Лучший час */}
<div className="card p-4">
<div className="text-sm font-semibold mb-1 flex items-center gap-2">
<Clock className="w-4 h-4 text-accent" /> Активность по часам (МСК, 90 дн.)
</div>
{bestHour.value > 0 && (
<p className="text-xs text-text-mute mb-3">
Пик публикаций в <span className="text-accent font-medium">{bestHour.i}:00</span>
</p>
)}
<div className="flex gap-px items-end overflow-x-auto" style={{ height: BAR_HEIGHT + 32 }}>
{hourData.map((d, i) => (
<Bar key={i} value={d.value} max={maxHour} label={i % 4 === 0 ? String(i) : ''} highlight={i === bestHour.i} />
))}
</div>
</div>
{/* Топ постов по реакциям */}
{topPosts.length > 0 && (
<div className="card p-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-400" /> Топ постов по реакциям
</div>
<div className="flex flex-col gap-3">
{topPosts.map(p => (
<div key={p.id} className="p-3 rounded-lg bg-surface2 border border-border">
<div className="text-xs text-text-soft line-clamp-2 mb-2">{p.preview}</div>
<div className="flex items-center gap-3 flex-wrap">
<ReactionBar reactions={p.reactions} />
{p.forwards > 0 && (
<span className="text-xs text-text-mute flex items-center gap-1">
<Share2 className="w-3 h-3" /> {p.forwards}
</span>
)}
<span className="text-xs text-text-mute ml-auto">
{p.published_at ? new Date(p.published_at).toLocaleDateString('ru', { day:'numeric', month:'short' }) : ''}
</span>
{p.tg_message_id && (
<a
href={`https://t.me/${channelName?.replace('@','')}/${p.tg_message_id}`}
target="_blank" rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
Открыть
</a>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Нет данных */}
{!loading && metrics && topPosts.length === 0 && reactionTotals.length === 0 && (
<div className="card p-8 text-center text-text-mute">
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Пока нет данных для отображения</p>
<p className="text-xs mt-1">Реакции появятся после первых публикаций через этот канал</p>
</div>
)}
</div>
);
}
+287 -26
View File
@@ -2,7 +2,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette } from 'lucide-react'; import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette, Plus, X, Sparkles, Plug } from 'lucide-react';
import TopicBank from './TopicBank';
const GOALS = [
{ v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' },
{ v: 'news', label: 'Новости', desc: 'Что произошло' },
{ v: 'entertainment', label: 'Развлечение', desc: 'Лёгкий контент, мемы' },
{ v: 'expert', label: 'Экспертный', desc: 'Глубокий анализ, инсайты' },
{ v: 'sales', label: 'Продажи', desc: 'Подвести к покупке' },
];
const TONES = [ const TONES = [
{ v: 'friendly', label: 'Дружелюбный' }, { v: 'friendly', label: 'Дружелюбный' },
@@ -32,14 +41,14 @@ const EMOJI = [
]; ];
const IMAGE_STYLES = [ const IMAGE_STYLES = [
{ v: 'realistic-photo', label: 'Реалистичное фото', desc: 'Стоковая фотография' }, { v: 'realistic-photo', label: 'Реалистичное фото', desc: 'AI-фотореализм, не сток' },
{ v: 'flat-illustration', label: 'Плоская иллюстрация', desc: 'Editorial vector' }, { v: 'flat-illustration',label: 'Плоская иллюстрация', desc: 'Editorial vector' },
{ v: '3d-render', label: '3D рендер', desc: 'Pixar-like' }, { v: '3d-render', label: '3D рендер', desc: 'Pixar-like' },
{ v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' }, { v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' },
{ v: 'minimal', label: 'Минимализм', desc: 'Один элемент' }, { v: 'minimal', label: 'Минимализм', desc: 'Один элемент' },
{ v: 'abstract', label: 'Абстракция', desc: 'Без объектов' }, { v: 'abstract', label: 'Абстракция', desc: 'Геометрия, настроение' },
{ v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' }, { v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' },
{ v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' }, { v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' },
]; ];
const IMAGE_PALETTES = [ const IMAGE_PALETTES = [
@@ -53,8 +62,10 @@ const IMAGE_PALETTES = [
]; ];
const TABS = [ const TABS = [
{ id: 'content', label: 'Контент', icon: Type }, { id: 'content', label: 'Контент', icon: Type },
{ id: 'images', label: 'Картинки', icon: ImageIcon }, { id: 'images', label: 'Картинки', icon: ImageIcon },
{ id: 'ai', label: 'AI-стиль', icon: Sparkles },
{ id: 'connect', label: 'Подключение', icon: Plug },
]; ];
export default function ChannelEdit({ channel }) { export default function ChannelEdit({ channel }) {
@@ -66,6 +77,11 @@ export default function ChannelEdit({ channel }) {
const [name, setName] = useState(channel.name || ''); const [name, setName] = useState(channel.name || '');
const [niche, setNiche] = useState(channel.niche || ''); const [niche, setNiche] = useState(channel.niche || '');
const [audience, setAudience] = useState(channel.audience || ''); const [audience, setAudience] = useState(channel.audience || '');
const [goals, setGoals] = useState(
channel.goal ? channel.goal.split(',').map(g => g.trim()).filter(Boolean) : ['educational']
);
const [customGoal, setCustomGoal] = useState('');
const [language, setLanguage] = useState(channel.language || 'ru');
const [tone, setTone] = useState(style.tone || 'friendly'); const [tone, setTone] = useState(style.tone || 'friendly');
const [formality, setFormality] = useState(style.formality || 'informal'); const [formality, setFormality] = useState(style.formality || 'informal');
const [humor, setHumor] = useState(style.humor || 'moderate'); const [humor, setHumor] = useState(style.humor || 'moderate');
@@ -77,9 +93,28 @@ export default function ChannelEdit({ channel }) {
// Картинки // Картинки
const [imageEnabled, setImageEnabled] = useState(style.image_enabled ?? false); const [imageEnabled, setImageEnabled] = useState(style.image_enabled ?? false);
const [imageStyle, setImageStyle] = useState(style.image_style || 'flat-illustration'); const [imageStyles, setImageStyles] = useState(
(style.image_style || 'flat-illustration').split(',').map(s => s.trim()).filter(Boolean)
);
const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto'); const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto');
const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || ''); const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || '');
const [imagePromptInstructions, setImagePromptInstructions] = useState(style.image_prompt_instructions || '');
// Подключение
const [botToken, setBotToken] = useState(channel.bot_token || '');
const [tgChannelId, setTgChannelId] = useState(channel.tg_channel_id || '');
const [tgUsername, setTgUsername] = useState(channel.tg_username || '');
const [vkToken, setVkToken] = useState(channel.vk_access_token || '');
const [tokenVerifying, setTokenVerifying] = useState(false);
const [tokenStatus, setTokenStatus] = useState(null); // null | 'ok' | 'error'
// AI-стиль
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
// Авто-черновики
const [autoDraftEnabled, setAutoDraftEnabled] = useState(channel.auto_draft_enabled || false);
const [autoDraftCount, setAutoDraftCount] = useState(channel.auto_draft_count || 3);
const [autoDraftTime, setAutoDraftTime] = useState(channel.auto_draft_time || '08:00');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -91,7 +126,16 @@ export default function ChannelEdit({ channel }) {
setError(''); setError('');
try { try {
const data = { const data = {
name, niche, audience, name, niche, audience, goal: goals.join(','), language,
bot_token: botToken.trim() || null,
tg_channel_id: tgChannelId.trim() || null,
tg_username: tgUsername.trim() || null,
vk_access_token: vkToken.trim() || null,
ai_style_prompt: aiStylePrompt.trim() || null,
image_quality: imageQuality,
auto_draft_enabled: autoDraftEnabled,
auto_draft_count: autoDraftCount,
auto_draft_time: autoDraftTime,
style: { style: {
tone, formality, humor, tone, formality, humor,
post_length: postLength, post_length: postLength,
@@ -100,9 +144,10 @@ export default function ChannelEdit({ channel }) {
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean), banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean), banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
image_enabled: imageEnabled, image_enabled: imageEnabled,
image_style: imageStyle, image_style: imageStyles.join(','),
image_palette: imagePalette, image_palette: imagePalette,
image_custom_colors: imageCustomColors.trim() || null, image_custom_colors: imageCustomColors.trim() || null,
image_prompt_instructions: imagePromptInstructions.trim() || null,
}, },
}; };
const res = await fetch(`/api/channels/${channel.id}`, { const res = await fetch(`/api/channels/${channel.id}`, {
@@ -183,6 +228,47 @@ export default function ChannelEdit({ channel }) {
<label className="label">Аудитория</label> <label className="label">Аудитория</label>
<textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} /> <textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} />
</div> </div>
<div>
<label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-2">
{GOALS.map(g => {
const on = goals.includes(g.v);
return (
<button key={g.v} type="button"
onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
className={`p-2.5 rounded-lg border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'}`}>
<div className="text-sm font-medium">{g.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
</button>
);
})}
</div>
<div className="flex gap-2">
<input className="input text-sm flex-1" placeholder="Своя цель — введи и нажми +"
value={customGoal} onChange={e => setCustomGoal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}} />
<button type="button" onClick={() => { const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}
disabled={!customGoal.trim()} className="btn-primary px-3 disabled:opacity-40"><Plus className="w-4 h-4" /></button>
</div>
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
{g}<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))}><X className="w-3 h-3" /></button>
</span>
))}
</div>
)}
</div>
<div>
<label className="label">Язык постов</label>
<select className="input" value={language} onChange={e => setLanguage(e.target.value)}>
<option value="ru">Русский</option>
<option value="en">English</option>
<option value="uk">Українська</option>
<option value="kk">Қазақша</option>
</select>
</div>
</div> </div>
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
@@ -281,22 +367,31 @@ export default function ChannelEdit({ channel }) {
{imageEnabled && ( {imageEnabled && (
<> <>
<div className="card p-5"> <div className="card p-5">
<h3 className="font-semibold text-sm mb-3 flex items-center gap-2"> <h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-accent" /> <ImageIcon className="w-4 h-4 text-accent" />
Стиль изображений Стиль изображений
<span className="text-gray-500 font-normal">(можно несколько система будет чередовать)</span>
</h3> </h3>
<p className="text-xs text-gray-500 mb-3">
Все стили это <b>AI-генерация</b>, не стоковые фото.
Если в посте упоминается реальный человек система автоматически ищет его фото в интернете вместо генерации.
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{IMAGE_STYLES.map(s => ( {IMAGE_STYLES.map(s => {
<button const on = imageStyles.includes(s.v);
key={s.v} type="button" onClick={() => setImageStyle(s.v)} return (
className={`p-3 rounded-lg border text-left transition-colors ${ <button
imageStyle === s.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600' key={s.v} type="button"
}`} onClick={() => setImageStyles(on ? imageStyles.filter(x => x !== s.v) : [...imageStyles, s.v])}
> className={`p-3 rounded-lg border text-left transition-colors ${
<div className="text-sm font-medium">{s.label}</div> on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div> }`}
</button> >
))} <div className="text-sm font-medium">{s.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
</button>
);
})}
</div> </div>
</div> </div>
@@ -329,6 +424,26 @@ export default function ChannelEdit({ channel }) {
</div> </div>
</div> </div>
{/* Инструкции для AI */}
<div className="card p-5">
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
Инструкции для AI
</h3>
<p className="text-xs text-gray-500 mb-3">
Опиши, какими должны быть картинки. Например: <em>«тёмный фон, минималистичные 3D-объекты, технологичная эстетика, без людей»</em>.
Применяется ко всем постам и обложкам статей этого канала.
</p>
<textarea
className="input min-h-[90px] text-sm"
value={imagePromptInstructions}
onChange={e => setImagePromptInstructions(e.target.value)}
placeholder="Примеры:&#10;— технологичный объект на тёмном градиентном фоне, как Stripe/Vercel blog&#10;— реалистичное фото молочного производства, без людей, фокус на деталях&#10;— мягкая пастельная акварель, природа и животные, тёплый тон"
maxLength={500}
/>
<div className="text-xs text-gray-500 text-right mt-1">{imagePromptInstructions.length}/500</div>
</div>
{/* Preview подсказка */} {/* Preview подсказка */}
<div className="card p-4 bg-accent/5 border-accent/20 text-sm text-gray-300"> <div className="card p-4 bg-accent/5 border-accent/20 text-sm text-gray-300">
<div className="font-medium text-accent mb-1">Как это работает</div> <div className="font-medium text-accent mb-1">Как это работает</div>
@@ -341,6 +456,152 @@ export default function ChannelEdit({ channel }) {
)} )}
</div> </div>
)} )}
{/* TAB: AI-стиль */}
{tab === 'ai' && (
<div className="space-y-5">
{/* Промт для генерации статей */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-accent" />
<h3 className="font-semibold text-sm">Стиль генерации статей</h3>
</div>
<p className="text-xs text-gray-400">
Дополнительные инструкции для AI при автоматической генерации статей в этом канале.
Применяется ко всем статьям канала. При ручной генерации можно переопределить.
</p>
<textarea
rows={5}
placeholder={`Например:\n• Пиши в стиле новостной заметки, без воды\n• Аудитория: молочные фермеры Сибири\n• Всегда заканчивай призывом к действию\n• Включай конкретные цифры и факты`}
value={aiStylePrompt}
onChange={e => setAiStylePrompt(e.target.value)}
className="input w-full text-sm resize-none"
/>
<p className="text-xs text-gray-500">
{aiStylePrompt.length}/1000 символов
</p>
</div>
{/* Модель генерации картинок — только gpt-5-image-mini */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-accent" />
<h3 className="font-semibold text-sm">Генерация картинок</h3>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center text-base">🖼</div>
<div className="flex-1">
<div className="text-sm font-medium">gpt-5-image-mini</div>
<div className="text-xs text-gray-400 mt-0.5">routerai.ru ~2.72/картинка high quality</div>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">Активна</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Единственная модель для генерации изображений. Параметр качества фиксирован провайдером.
</p>
</div>
{/* Банк тем */}
<TopicBank channelId={channel.id} />
{/* Авто-черновики */}
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm flex items-center gap-2">
<span></span> Авто-генерация черновиков
</h3>
<p className="text-xs text-gray-400 mt-0.5">
Система генерирует посты каждый день ты одобряешь вечером
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer"
checked={autoDraftEnabled}
onChange={e => setAutoDraftEnabled(e.target.checked)} />
<div className="w-10 h-5 bg-gray-600 peer-focus:outline-none rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-0.5 after:left-[2px]
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
peer-checked:bg-accent" />
</label>
</div>
{autoDraftEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label text-xs mb-1">Постов в день</label>
<select value={autoDraftCount} onChange={e => setAutoDraftCount(+e.target.value)}
className="input w-full text-sm py-1.5">
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n} {n===1?'пост':'постов'}</option>)}
</select>
</div>
<div>
<label className="label text-xs mb-1">Время генерации</label>
<input type="time" value={autoDraftTime}
onChange={e => setAutoDraftTime(e.target.value)}
className="input w-full text-sm py-1.5" />
</div>
</div>
)}
<p className="text-xs text-gray-500">
Черновики появляются на странице{' '}
<a href="/drafts" target="_blank" className="text-accent hover:underline">Черновики</a>.
Там можно редактировать, одобрять и планировать публикацию.
</p>
</div>
</div>
)}
{/* TAB: Подключение */}
{tab === 'connect' && (
<div className="space-y-5">
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg"></span>
<h3 className="font-semibold">Telegram</h3>
</div>
<div className="bg-surface2/50 rounded-lg p-3 text-xs text-gray-400 space-y-1 border border-border">
<div className="font-medium text-gray-300 mb-2">Как подключить:</div>
<div>1. Создай бота через <span className="text-accent">@BotFather</span> <code>/newbot</code> скопируй токен</div>
<div>2. Добавь бота <b>администратором</b> в свой канал (права: публикация сообщений)</div>
<div>3. Узнай ID канала: перешли сообщение из канала боту <span className="text-accent">@idbot</span> вернёт ID вида <code>-100xxxxxxxxxx</code></div>
</div>
<div>
<label className="label">Bot Token</label>
<input className="input font-mono text-sm" placeholder="7123456789:AAHxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
value={botToken} onChange={e => { setBotToken(e.target.value); setTokenStatus(null); }} type="password" />
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">ID канала</label>
<input className="input font-mono text-sm" placeholder="-1001234567890"
value={tgChannelId} onChange={e => setTgChannelId(e.target.value)} />
<div className="hint">Начинается с -100</div>
</div>
<div>
<label className="label">Username канала</label>
<input className="input font-mono text-sm" placeholder="@mychannel"
value={tgUsername} onChange={e => setTgUsername(e.target.value)} />
<div className="hint">Необязательно если заполнен ID</div>
</div>
</div>
</div>
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">🅱</span>
<h3 className="font-semibold">ВКонтакте</h3>
</div>
<div>
<label className="label">Access Token группы</label>
<input className="input font-mono text-sm" placeholder="vk1.a.xxx..."
value={vkToken} onChange={e => setVkToken(e.target.value)} type="password" />
<div className="hint">Управление API Ключи доступа Создать ключ (права: wall, photos)</div>
</div>
</div>
</div>
)}
</main> </main>
); );
} }
+330 -47
View File
@@ -4,8 +4,17 @@ 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, History MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
Search, Camera, ExternalLink, Link2
} from 'lucide-react'; } from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal';
import PostPreview from './PostPreview';
import PostTemplates from './PostTemplates';
import ChannelAnalytics from './ChannelAnalytics';
import FromUrlModal from './FromUrlModal';
import PollModal from './PollModal';
import HashtagSuggest from './HashtagSuggest';
import InboxTab from './InboxTab';
const GOAL_LABELS = { const GOAL_LABELS = {
educational: 'Обучение', news: 'Новости', educational: 'Обучение', news: 'Новости',
@@ -22,8 +31,19 @@ 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 [customPrompt, setCustomPrompt] = useState('');
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [post, setPost] = useState(null); const [post, setPost] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -36,8 +56,16 @@ 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 [showFromUrl, setShowFromUrl] = useState(false);
const [showPoll, setShowPoll] = useState(false);
const [batchCount, setBatchCount] = useState(3);
const [batchLoading, setBatchLoading] = useState(false);
// Трансформации // Трансформации
const [transforming, setTransforming] = useState(false); const [transforming, setTransforming] = useState(false);
@@ -69,12 +97,12 @@ export default function ChannelView({ channel }) {
// Сохранение и публикация // Сохранение и публикация
const [savedPostId, setSavedPostId] = useState(null); const [savedPostId, setSavedPostId] = useState(null);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [activeTab, setActiveTab] = useState('generate'); // generate | analytics
const [showScheduler, setShowScheduler] = useState(false); const [showScheduler, setShowScheduler] = useState(false);
const [scheduleAt, setScheduleAt] = useState(''); const [scheduleAt, setScheduleAt] = useState('');
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 +114,30 @@ 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);
}
function applyFromUrl({ content, imageUrl, title }) {
setPost(content);
if (imageUrl) setImage(imageUrl);
if (title && !topic.trim()) setTopic(title.slice(0, 120));
setSavedPostId(null);
}
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 +145,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 +159,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 +193,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,9 +208,18 @@ export default function ChannelView({ channel }) {
setPost(null); setPost(null);
setSavedPostId(null); setSavedPostId(null);
setImage(null); setImage(null);
setImageCredit(null);
setTopic(''); setTopic('');
} }
function applyTemplate({ topicHint, structure }) {
// Если тема не задана — подставляем подсказку
if (!topic.trim()) setTopic(topicHint);
// В textarea вставляем структуру как отправную точку
setPost(structure);
setSavedPostId(null);
}
async function generate(asVariant = false) { async function generate(asVariant = false) {
if (!topic.trim() && !asVariant) return; if (!topic.trim() && !asVariant) return;
if (asVariant && !post) return; if (asVariant && !post) return;
@@ -168,10 +234,18 @@ export default function ChannelView({ channel }) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true, type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
customPrompt: customPrompt.trim() || undefined,
}), }),
}); });
const job = await createRes.json(); const job = await createRes.json();
if (!createRes.ok) throw new Error(job.error || 'Ошибка'); if (!createRes.ok) {
if (job.code === 'INSUFFICIENT_CREDITS') {
throw new Error(`Недостаточно кредитов: нужно ${job.cost}, есть ${job.credits}. Пополните баланс на странице тарифов.`);
}
throw new Error(job.error || 'Ошибка');
}
// Триггер обновления баланса в header
if (job.credits_after !== null) window.dispatchEvent(new Event('credits-updated'));
let final; let final;
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
@@ -183,14 +257,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 +284,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 +308,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 +320,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() {
@@ -271,7 +347,7 @@ export default function ChannelView({ channel }) {
<Sparkles className="w-5 h-5 text-accent" /> <Sparkles className="w-5 h-5 text-accent" />
<h1 className="text-2xl font-bold">{channel.name}</h1> <h1 className="text-2xl font-bold">{channel.name}</h1>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400"> <span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
{GOAL_LABELS[channel.goal] || channel.goal} {(channel.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')}
</span> </span>
</div> </div>
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>} {channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
@@ -286,6 +362,26 @@ export default function ChannelView({ channel }) {
</Link> </Link>
</div> </div>
{/* Вкладки */}
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
{[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
<button key={id} onClick={() => setActiveTab(id)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
{label}
</button>
))}
</div>
{activeTab === 'analytics' && (
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
)}
{activeTab === 'inbox' && (
<InboxTab channel={channel} />
)}
{activeTab === 'generate' && <>
{/* Generator */} {/* Generator */}
<div className="card p-5 mb-6"> <div className="card p-5 mb-6">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2"> <div className="flex items-center justify-between mb-3 flex-wrap gap-2">
@@ -293,17 +389,60 @@ export default function ChannelView({ channel }) {
<Wand2 className="w-4 h-4 text-accent" /> <Wand2 className="w-4 h-4 text-accent" />
Сгенерировать пост Сгенерировать пост
</h2> </h2>
<button <div className="flex items-center gap-3">
onClick={fetchIdeas} <PostTemplates onSelect={applyTemplate} disabled={generating} />
disabled={loadingIdeas} <button
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50" onClick={() => setShowFromUrl(true)}
> className="text-xs inline-flex items-center gap-1 text-accent hover:underline"
{loadingIdeas ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles 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>
)}
{/* Batch-генерация черновиков */}
<div className="flex items-center gap-1">
<button
onClick={async () => {
setBatchLoading(true);
try {
const res = await fetch(`/api/channels/${channel.id}/drafts/generate?count=${batchCount}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
}).then(r => r.json());
if (res.ok) alert(`✅ Генерирую ${batchCount} черновиков — через несколько минут появятся в /drafts`);
else alert(res.error || 'Ошибка');
} catch { alert('Ошибка'); }
setBatchLoading(false);
}}
disabled={batchLoading}
className="text-xs inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 transition-colors"
>
<span>{batchLoading ? '⏳' : '⚡'}</span>
Авто ×
</button>
<select value={batchCount} onChange={e => setBatchCount(+e.target.value)}
className="text-xs bg-surface2 border border-border rounded px-1 py-0.5 text-gray-400">
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<button
onClick={fetchIdeas}
disabled={loadingIdeas}
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
>
{loadingIdeas ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
Идеи тем
</button>
</div>
</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">
@@ -327,15 +466,49 @@ export default function ChannelView({ channel }) {
)} )}
<textarea <textarea
className="input min-h-[80px] mb-3" className="input min-h-[80px] mb-2"
value={topic} value={topic}
onChange={e => setTopic(e.target.value)} onChange={e => setTopic(e.target.value)}
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»" placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
disabled={generating} disabled={generating}
/> />
{/* Дополнительные инструкции */}
<div className="mb-3">
<button
type="button"
onClick={() => setShowCustomPrompt(v => !v)}
className="text-xs text-gray-500 hover:text-accent flex items-center gap-1 transition-colors"
>
<span>{showCustomPrompt ? '▾' : '▸'}</span>
Дополнительные инструкции для AI
{customPrompt.trim() && <span className="ml-1 w-1.5 h-1.5 rounded-full bg-accent inline-block" />}
</button>
{showCustomPrompt && (
<div className="mt-2 space-y-1">
<textarea
rows={3}
className="input w-full text-sm resize-none"
placeholder={`Например: «Сделай акцент на кейсах из сельского хозяйства» или «Добавь призыв подписаться в конце»`}
value={customPrompt}
onChange={e => setCustomPrompt(e.target.value)}
disabled={generating}
/>
<p className="text-xs text-gray-500">
Перебивает стиль канала для этой генерации.
{channel.ai_style_prompt && ' Канальный промт также будет применён.'}
</p>
</div>
)}
</div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500 space-y-0.5">
ИИ напишет пост в стиле твоего канала с учётом примеров <div>ИИ напишет пост в стиле твоего канала с учётом примеров</div>
<div className="flex items-center gap-1.5 text-gray-600">
<span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">2 кр текст</span>
{channel.image_enabled && <span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">+ 5 кр картинка</span>}
</div>
</div> </div>
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary"> <button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? ( {generating ? (
@@ -354,7 +527,8 @@ export default function ChannelView({ channel }) {
{/* Result */} {/* Result */}
{post && ( {post && (
<div className="card p-5 mb-4"> <div className="grid lg:grid-cols-[1fr_360px] gap-4 mb-4 items-start">
<div className="card p-5">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2"> <div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="font-semibold flex items-center gap-2"> <h3 className="font-semibold flex items-center gap-2">
Результат Результат
@@ -388,7 +562,6 @@ export default function ChannelView({ channel }) {
</div> </div>
</div> </div>
{/* Сам пост — редактируемый или нет */}
{editing ? ( {editing ? (
<textarea <textarea
value={post} value={post}
@@ -402,32 +575,93 @@ 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 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>
)} )}
@@ -461,7 +695,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>
@@ -500,8 +733,47 @@ export default function ChannelView({ channel }) {
</div> </div>
</div> </div>
</div> </div>
{/* Правая колонка — превью */}
<div className="card p-4 sticky top-20">
<PostPreview
text={post}
imageUrl={image}
platform={channel.platform || 'telegram'}
channelName={channel.name}
/>
</div>
</div>
)} )}
{/* From URL modal */}
<FromUrlModal
open={showFromUrl}
channelId={channel.id}
onClose={() => setShowFromUrl(false)}
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 */}
<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">
@@ -545,11 +817,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>
@@ -562,13 +834,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"
> >
открыть открыть
@@ -579,6 +861,7 @@ export default function ChannelView({ channel }) {
</div> </div>
</div> </div>
)} )}
</> }
</main> </main>
); );
} }
+191
View File
@@ -0,0 +1,191 @@
'use client';
/**
* FromUrlModal — модальное окно «URL → черновик».
* Props:
* open — bool
* channelId — number
* onClose()
* onApply({ content, imageUrl, title }) — вызывается с результатом
*/
import { useState } from 'react';
import { X, Link2, Loader2, Youtube, Globe, Send, AlertCircle } from 'lucide-react';
function detectSource(url) {
try {
const u = new URL(url);
if (u.hostname.includes('youtube.com') || u.hostname.includes('youtu.be')) return 'youtube';
if (u.hostname === 't.me') return 'telegram';
return 'web';
} catch { return 'web'; }
}
const SOURCE_ICONS = {
youtube: { Icon: Youtube, label: 'YouTube', color: 'text-red-400' },
telegram: { Icon: Send, label: 'Telegram', color: 'text-blue-400' },
web: { Icon: Globe, label: 'Сайт', color: 'text-text-mute' },
};
export default function FromUrlModal({ open, channelId, onClose, onApply }) {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null);
const [edited, setEdited] = useState('');
if (!open) return null;
const source = detectSource(url);
const { Icon: SrcIcon, label: srcLabel, color: srcColor } = SOURCE_ICONS[source] || SOURCE_ICONS.web;
async function generate() {
if (!url.trim()) return;
setLoading(true);
setError('');
setResult(null);
try {
const res = await fetch('/api/generate/from-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId, url: url.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка генерации');
setResult(data);
setEdited(data.content);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
function apply() {
if (!edited.trim()) return;
onApply({
content: edited,
imageUrl: result?.imageUrl || null,
title: result?.title || '',
});
handleClose();
}
function handleClose() {
setUrl('');
setResult(null);
setEdited('');
setError('');
onClose();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-2xl bg-surface border border-border shadow-2xl flex flex-col max-h-[90vh]">
{/* Шапка */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<h2 className="font-semibold flex items-center gap-2">
<Link2 className="w-4 h-4 text-accent" />
URL черновик
</h2>
<button onClick={handleClose} className="btn-ghost p-1.5 rounded-lg">
<X className="w-4 h-4" />
</button>
</div>
{/* Контент */}
<div className="flex flex-col gap-4 p-5 overflow-y-auto">
{/* Инпут URL */}
<div>
<label className="label">Ссылка на статью, YouTube-видео или Telegram-пост</label>
<div className="relative">
<div className={`absolute left-3 top-1/2 -translate-y-1/2 ${srcColor}`}>
<SrcIcon className="w-4 h-4" />
</div>
<input
type="url"
className="input pl-9 pr-3"
placeholder="https://..."
value={url}
onChange={e => { setUrl(e.target.value); setResult(null); setError(''); }}
disabled={loading}
onKeyDown={e => e.key === 'Enter' && !loading && generate()}
/>
</div>
{url && (
<p className="hint mt-1">Источник: {srcLabel}</p>
)}
</div>
{/* Кнопка генерации */}
{!result && (
<button
onClick={generate}
disabled={loading || !url.trim()}
className="btn-primary w-full"
>
{loading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Читаю и генерирую</>
) : (
<><Link2 className="w-4 h-4" /> Сгенерировать пост</>
)}
</button>
)}
{/* Ошибка */}
{error && (
<div className="card p-3 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" /> {error}
</div>
)}
{/* Результат */}
{result && (
<>
{result.title && (
<div className="text-xs text-text-mute border-l-2 border-accent/40 pl-3 py-1">
Источник: {result.title}
</div>
)}
<div>
<label className="label">Черновик поста отредактируй и применяй</label>
<textarea
className="input min-h-[180px] text-sm leading-relaxed"
value={edited}
onChange={e => setEdited(e.target.value)}
autoFocus
/>
<p className="hint">{edited.length} символов</p>
</div>
{result.imageUrl && (
<div>
<label className="label">Обложка из источника</label>
<img
src={result.imageUrl}
alt=""
className="w-full rounded-lg max-h-40 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
</div>
)}
<div className="flex gap-2">
<button onClick={apply} className="btn-primary flex-1">
Применить в редактор
</button>
<button onClick={() => { setResult(null); setEdited(''); }} className="btn-ghost">
Заново
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+119
View File
@@ -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>
);
}
+54 -1
View File
@@ -1,11 +1,23 @@
'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 { useEffect, useState } from 'react';
import { Sparkles, LogOut, CalendarDays, Coins, FileText, Settings2 } from 'lucide-react';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
export default function Header({ user }) { export default function Header({ user }) {
const router = useRouter(); const router = useRouter();
const [credits, setCredits] = useState(null);
useEffect(() => {
if (!user) return;
const refresh = () => fetch('/api/billing/balance').then(r => r.json())
.then(d => setCredits(d.isUnlimited ? '∞' : d.credits)).catch(() => {});
refresh();
window.addEventListener('credits-updated', refresh);
return () => window.removeEventListener('credits-updated', refresh);
}, [user?.id]);
async function logout() { async function logout() {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login'); router.push('/login');
@@ -17,7 +29,30 @@ export default function Header({ user }) {
<Sparkles className="w-5 h-5 text-accent" /> <Sparkles className="w-5 h-5 text-accent" />
<span className="font-bold">ZeroPost</span> <span className="font-bold">ZeroPost</span>
</Link> </Link>
<nav className="hidden sm:flex items-center gap-1">
<Link href="/calendar" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<CalendarDays className="w-4 h-4" />
<span>Календарь</span>
</Link>
<Link href="/drafts" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<FileText className="w-4 h-4" />
<span>Черновики</span>
</Link>
{user?.isAdmin && (
<Link href="/system" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<Settings2 className="w-4 h-4" />
<span>Админ</span>
</Link>
)}
</nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Баланс кредитов */}
{credits !== null && (
<Link href="/billing" className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-accent/10 hover:bg-accent/20 transition-colors text-sm font-medium text-accent">
<Coins className="w-3.5 h-3.5" />
<span>{credits} кр</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="Выйти">
@@ -28,3 +63,21 @@ export default function Header({ user }) {
</header> </header>
); );
} }
// Публичный хедер для лендинга — отдельный экспорт
export function PublicHeader() {
return (
<header className="border-b border-border bg-surface sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<Link href="/landing" className="flex items-center gap-2 font-bold">
<Sparkles className="w-5 h-5 text-accent" />
<span>ZeroPost</span>
</Link>
<div className="flex items-center gap-2">
<Link href="/login" className="btn-ghost text-sm px-3 py-1.5">Войти</Link>
<Link href="/register" className="btn-primary text-sm px-3 py-1.5">Начать бесплатно</Link>
</div>
</div>
</header>
);
}
+249
View File
@@ -0,0 +1,249 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { MessageCircle, RefreshCw, Send, X, Ban, CheckCheck, Loader2, Bell, BellOff } from 'lucide-react';
const STATUS_TABS = [
{ v: 'new', label: 'Новые', color: 'text-accent' },
{ v: 'all', label: 'Все', color: 'text-gray-400' },
{ v: 'replied', label: 'Отвечено', color: 'text-green-400' },
{ v: 'ignored', label: 'Игнорировано', color: 'text-gray-500' },
];
const TYPE_ICONS = {
question: '❓',
praise: '👍',
complaint: '😤',
spam: '🚫',
other: '💬',
};
const TYPE_COLORS = {
question: 'border-blue-500/30 bg-blue-500/5',
praise: 'border-green-500/30 bg-green-500/5',
complaint: 'border-red-500/30 bg-red-500/5',
spam: 'border-gray-600 bg-gray-800/50 opacity-60',
other: 'border-border',
};
function fmtDate(s) {
const d = new Date(s);
const now = new Date();
const diff = now - d;
if (diff < 60000) return 'только что';
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
if (diff < 86400000) return Math.floor(diff/3600000) + 'ч назад';
return d.toLocaleDateString('ru-RU');
}
export default function InboxTab({ channel }) {
const [tab, setTab] = useState('new');
const [messages, setMessages] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [replyId, setReplyId] = useState(null);
const [replyText,setReplyText]= useState('');
const [sending, setSending] = useState(false);
const [webhookOk,setWebhookOk]= useState(channel.tg_webhook_enabled);
const [setupping,setSetuppping]= useState(false);
const load = useCallback(async (t = tab) => {
setLoading(true);
try {
const res = await fetch(`/api/inbox/channel/${channel.id}?status=${t}&limit=40`).then(r => r.json());
setMessages(res.messages || []);
setTotal(res.total || 0);
} catch {}
setLoading(false);
}, [channel.id, tab]);
useEffect(() => { load(tab); }, [tab]);
async function setupWebhook() {
setSetuppping(true);
try {
const res = await fetch(`/api/inbox/channel/${channel.id}/setup-webhook`, { method: 'POST' }).then(r => r.json());
if (res.ok) { setWebhookOk(true); load(tab); }
else alert(res.error || 'Ошибка');
} catch { alert('Ошибка соединения'); }
setSetuppping(false);
}
async function sendReply(msg) {
if (!replyText.trim()) return;
setSending(true);
try {
const res = await fetch(`/api/inbox/message/${msg.id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: replyText }),
}).then(r => r.json());
if (res.ok) {
setReplyId(null);
setReplyText('');
load(tab);
} else alert(res.error || 'Ошибка');
} catch { alert('Ошибка'); }
setSending(false);
}
async function setStatus(msgId, status) {
await fetch(`/api/inbox/message/${msgId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
load(tab);
}
const newCount = messages.filter(m => m.status === 'new').length;
return (
<div className="space-y-4">
{/* Webhook setup */}
{!webhookOk && (
<div className="card p-4 border-yellow-500/30 bg-yellow-500/5">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm flex items-center gap-2">
<BellOff className="w-4 h-4 text-yellow-400" />
Webhook не настроен
</div>
<div className="text-xs text-gray-400 mt-1">
Нужно подключить webhook чтобы получать комментарии
</div>
</div>
<button onClick={setupWebhook} disabled={setupping} className="btn-primary text-sm px-3 py-1.5">
{setupping ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Bell className="w-3.5 h-3.5 mr-1" />Подключить</>}
</button>
</div>
</div>
)}
{webhookOk && (
<div className="flex items-center gap-1 text-xs text-green-400">
<Bell className="w-3 h-3" /> Webhook активен получаем комментарии
</div>
)}
{/* Tabs */}
<div className="flex items-center justify-between">
<div className="flex gap-1">
{STATUS_TABS.map(t => (
<button key={t.v} onClick={() => setTab(t.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
tab === t.v ? `bg-accent/10 ${t.color} font-medium` : 'text-gray-500 hover:text-gray-300'
}`}>
{t.label}
{t.v === 'new' && newCount > 0 && (
<span className="ml-1.5 bg-accent text-white text-xs rounded-full px-1.5 py-0.5">{newCount}</span>
)}
</button>
))}
</div>
<button onClick={() => load(tab)} className="btn-ghost p-2">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Messages */}
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && messages.length === 0 && (
<div className="py-12 text-center text-gray-500">
<MessageCircle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<div className="text-sm">{tab === 'new' ? 'Новых комментариев нет' : 'Нет сообщений'}</div>
</div>
)}
{!loading && messages.map(msg => (
<div key={msg.id} className={`card p-4 border ${TYPE_COLORS[msg.ai_type] || TYPE_COLORS.other}`}>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<span className="text-base">{TYPE_ICONS[msg.ai_type] || '💬'}</span>
<div>
<span className="font-medium text-sm">
{msg.from_name || msg.from_username || 'Аноним'}
</span>
{msg.from_username && (
<span className="text-xs text-gray-500 ml-1">@{msg.from_username}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 shrink-0">
{msg.status === 'replied' && <CheckCheck className="w-3.5 h-3.5 text-green-400" />}
{fmtDate(msg.created_at)}
</div>
</div>
{/* Text */}
<p className="text-sm leading-relaxed mb-3 text-gray-200">{msg.text}</p>
{/* AI Reply Suggestion */}
{msg.ai_reply && msg.status === 'new' && (
<div className="mb-3 p-3 rounded-lg bg-surface2 border border-accent/20">
<div className="text-xs text-accent mb-1.5 font-medium"> Предложенный ответ AI</div>
<p className="text-sm text-gray-300">{msg.ai_reply}</p>
<button
className="mt-2 text-xs text-accent hover:underline"
onClick={() => { setReplyId(msg.id); setReplyText(msg.ai_reply); }}
>
Использовать
</button>
</div>
)}
{/* Reply form */}
{replyId === msg.id ? (
<div className="space-y-2">
<textarea
rows={3}
value={replyText}
onChange={e => setReplyText(e.target.value)}
className="input w-full text-sm resize-none"
placeholder="Текст ответа..."
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => sendReply(msg)} disabled={sending || !replyText.trim()}
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
{sending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
Отправить
</button>
<button onClick={() => { setReplyId(null); setReplyText(''); }}
className="btn-ghost text-sm px-3 py-1.5">
Отмена
</button>
</div>
</div>
) : msg.status === 'new' ? (
<div className="flex gap-2">
<button onClick={() => { setReplyId(msg.id); setReplyText(''); }}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1">
<Send className="w-3 h-3" /> Ответить
</button>
<button onClick={() => setStatus(msg.id, 'ignored')}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1 text-gray-500">
<X className="w-3 h-3" /> Игнорировать
</button>
<button onClick={() => setStatus(msg.id, 'spam')}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1 text-red-500">
<Ban className="w-3 h-3" /> Спам
</button>
</div>
) : (
<div className="text-xs text-gray-500">
{msg.status === 'replied' && `✓ Отвечено: ${msg.replied_text?.slice(0, 80)}...`}
{msg.status === 'ignored' && '— Проигнорировано'}
{msg.status === 'spam' && '🚫 Помечено как спам'}
</div>
)}
</div>
))}
{total > messages.length && (
<p className="text-xs text-center text-gray-500">Показано {messages.length} из {total}</p>
)}
</div>
);
}
+287
View File
@@ -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>
);
}
+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>
);
}
+325
View File
@@ -0,0 +1,325 @@
'use client';
/**
* PostPreview — рендерит пост так, как он будет выглядеть в TG / VK / MAX.
* Props:
* text — текст поста (Markdown-разметка TG)
* imageUrl — URL картинки (опционально)
* platform — 'telegram' | 'vk' | 'max'
* channelName — название канала (для шапки)
*/
import { useState } from 'react';
import { Eye, EyeOff, MessageCircle, Heart, Share2, Bookmark,
ThumbsUp, BarChart2, AlertCircle } from 'lucide-react';
// ── Лимиты платформ ───────────────────────────────────────────────────────────
const LIMITS = {
telegram: { text: 4096, caption: 1024, label: 'Telegram' },
vk: { text: 16384, caption: 2048, label: 'ВКонтакте' },
max: { text: 4096, caption: 1024, label: 'MAX' },
};
// ── Парсер разметки → HTML ────────────────────────────────────────────────────
function parseTgMarkdown(text) {
// Экранируем HTML-спецсимволы
let s = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// **bold** или __bold__
s = s.replace(/\*\*(.+?)\*\*/gs, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/gs, '<strong>$1</strong>');
// _italic_ или *italic*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/gs, '<em>$1</em>');
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/gs, '<em>$1</em>');
// `code`
s = s.replace(/`([^`]+)`/g, '<code class="tg-code">$1</code>');
// ```block```
s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, '<pre class="tg-pre">$1</pre>');
// [text](url)
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,
'<a href="$2" class="tg-link" target="_blank" rel="noopener">$1</a>');
// Переносы строк → <br>
s = s.replace(/\n/g, '<br>');
return s;
}
function parseVkMarkdown(text) {
// VK не поддерживает Markdown — убираем разметку, оставляем текст
let s = text
.replace(/\*\*(.+?)\*\*/gs, '$1')
.replace(/__(.+?)__/gs, '$1')
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/gs, '$1')
.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/gs, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/```[\w]*\n?([\s\S]*?)```/g, '$1')
.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g, '$1 ($2)')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return s;
}
function renderMarkdown(text, platform) {
if (!text) return '';
if (platform === 'vk') return parseVkMarkdown(text);
return parseTgMarkdown(text); // telegram + max
}
// ── Счётчик символов ──────────────────────────────────────────────────────────
function CharCounter({ text, imageUrl, platform }) {
const limits = LIMITS[platform] || LIMITS.telegram;
const limit = imageUrl ? limits.caption : limits.text;
const len = (text || '').length;
const pct = Math.min(len / limit, 1);
const over = len > limit;
const color = over ? 'text-red-400' : pct > 0.85 ? 'text-yellow-400' : 'text-text-mute';
return (
<div className={`flex items-center gap-2 text-xs ${color}`}>
{over && <AlertCircle className="w-3 h-3 shrink-0" />}
<span>{len} / {limit}{imageUrl ? ' (caption)' : ''}</span>
{over && <span className="font-medium">превышен лимит</span>}
</div>
);
}
// ── TG Preview ────────────────────────────────────────────────────────────────
function TelegramPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'telegram');
return (
<div className="bg-[#17212b] rounded-2xl overflow-hidden text-[#e0e0e0] text-sm font-sans max-w-sm mx-auto shadow-xl">
{/* Header */}
<div className="px-4 py-2 bg-[#1c2733] flex items-center gap-3 border-b border-white/5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'Z').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-white text-xs">{channelName || 'Канал'}</div>
<div className="text-[10px] text-[#5d8299]">только что</div>
</div>
</div>
{/* Контент */}
<div className="p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-xl mb-2 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
<div
className="leading-[1.5] break-words text-[13px]"
style={{ color: '#e0e0e0' }}
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
</div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center justify-between text-[#5d8299] text-[11px]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> 1.2K</span>
<span className="flex items-center gap-1"><Share2 className="w-3 h-3" /> 14</span>
</div>
<div className="flex items-center gap-2">
<MessageCircle className="w-3.5 h-3.5" />
<Bookmark className="w-3.5 h-3.5" />
</div>
</div>
{/* TG inline styles */}
<style>{`
.tg-code { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 4px; font-family: monospace; font-size: 12px; }
.tg-pre { background: rgba(255,255,255,0.07); border-radius: 6px; padding: 8px; font-family: monospace; font-size: 11px; white-space: pre-wrap; margin: 6px 0; }
.tg-link { color: #5bc8f5; text-decoration: none; }
.tg-link:hover { text-decoration: underline; }
`}</style>
</div>
);
}
// ── VK Preview ────────────────────────────────────────────────────────────────
function VkPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'vk');
return (
<div className="bg-[#f2f3f5] rounded-2xl overflow-hidden text-[#2c2d2e] text-sm font-sans max-w-sm mx-auto shadow-lg">
{/* Header */}
<div className="px-4 py-3 bg-white flex items-center gap-3 border-b border-[#e0e0e0]">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[#5181b8] to-[#2c5999] flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'K').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-[#2c2d2e] text-xs">{channelName || 'Сообщество'}</div>
<div className="text-[10px] text-[#818c99]">только что</div>
</div>
<div className="ml-auto text-[#818c99] text-[10px]">···</div>
</div>
{/* Контент */}
<div className="px-4 py-3 bg-white">
<div
className="leading-[1.55] break-words text-[13px] text-[#2c2d2e]"
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-lg mt-3 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-white border-t border-[#e7e8ec] flex items-center gap-4 text-[#818c99] text-xs">
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<ThumbsUp className="w-3.5 h-3.5" /> 24
</button>
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<MessageCircle className="w-3.5 h-3.5" /> 3
</button>
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<Share2 className="w-3.5 h-3.5" /> Поделиться
</button>
<span className="ml-auto flex items-center gap-1">
<Eye className="w-3 h-3" /> 891
</span>
</div>
</div>
);
}
// ── MAX Preview ───────────────────────────────────────────────────────────────
function MaxPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'max');
return (
<div className="bg-[#1a1a1a] rounded-2xl overflow-hidden text-[#d0d0d0] text-sm font-sans max-w-sm mx-auto shadow-xl">
{/* Header */}
<div className="px-4 py-2.5 bg-[#222] flex items-center gap-3 border-b border-white/5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'M').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-white text-xs">{channelName || 'Канал MAX'}</div>
<div className="text-[10px] text-[#666]">только что</div>
</div>
</div>
{/* Контент */}
<div className="p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-xl mb-2 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
<div
className="leading-[1.5] break-words text-[13px] text-[#d0d0d0]"
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
</div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center gap-4 text-[#555] text-[11px]">
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> 432</span>
<span className="flex items-center gap-1"><Heart className="w-3 h-3" /> 18</span>
<span className="flex items-center gap-1"><BarChart2 className="w-3 h-3" /> Опрос</span>
</div>
</div>
);
}
// ── Главный экспорт ───────────────────────────────────────────────────────────
const PLATFORM_ORDER = ['telegram', 'vk', 'max'];
const PLATFORM_LABELS = { telegram: 'TG', vk: 'VK', max: 'MAX' };
export default function PostPreview({ text, imageUrl, platform: defaultPlatform = 'telegram', channelName }) {
const [visible, setVisible] = useState(true);
const [platform, setPlatform] = useState(defaultPlatform);
if (!visible) {
return (
<button
onClick={() => setVisible(true)}
className="flex items-center gap-1.5 text-xs text-text-mute hover:text-text transition-colors"
>
<Eye className="w-3.5 h-3.5" /> Показать превью
</button>
);
}
return (
<div className="flex flex-col gap-3">
{/* Тулбар */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-text-soft uppercase tracking-wide">Превью</span>
<div className="flex items-center gap-1">
{/* Переключатель платформы */}
<div className="flex rounded-lg overflow-hidden border border-border text-xs">
{PLATFORM_ORDER.map(p => (
<button
key={p}
onClick={() => setPlatform(p)}
className={`px-2.5 py-1 font-medium transition-colors
${platform === p
? 'bg-accent text-white'
: 'bg-surface2 text-text-mute hover:text-text'
}`}
>
{PLATFORM_LABELS[p]}
</button>
))}
</div>
{/* Скрыть */}
<button
onClick={() => setVisible(false)}
className="btn-ghost p-1.5 rounded-lg"
title="Скрыть превью"
>
<EyeOff className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Счётчик символов */}
<CharCounter text={text} imageUrl={imageUrl} platform={platform} />
{/* Превью платформы */}
{platform === 'telegram' && (
<TelegramPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{platform === 'vk' && (
<VkPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{platform === 'max' && (
<MaxPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{/* Лимит */}
<div className="text-[10px] text-text-mute text-center">
{LIMITS[platform].label}: текст до {LIMITS[platform].text.toLocaleString()} симв.
{', caption (с фото) до '}{LIMITS[platform].caption.toLocaleString()} симв.
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
'use client';
/**
* PostTemplates — 7 кнопок-пресетов структуры поста.
* Props:
* onSelect(template) — вызывается с объектом { label, topic, structure }
* disabled — bool
*/
import { useState } from 'react';
import { ChevronDown, ChevronUp, Newspaper, Megaphone,
Briefcase, BookOpen, List, HelpCircle, User } from 'lucide-react';
const TEMPLATES = [
{
id: 'news',
label: 'Новость',
Icon: Newspaper,
hint: 'Факт → контекст → вывод',
topicHint: 'Новость или событие в нише',
structure: `[ЗАГОЛОВОК — суть в одной строке]
[2–3 предложения: что произошло, ключевые цифры]
[Почему это важно для читателя]
[Личный вывод или вопрос к аудитории]`,
},
{
id: 'announce',
label: 'Анонс',
Icon: Megaphone,
hint: 'Интрига → суть → CTA',
topicHint: 'Анонс события, релиза, запуска',
structure: `[Интригующий первый абзац — зачем читать дальше]
📅 [Дата и что именно происходит]
✅ [3–4 буллита: что будет / что получит читатель]
👉 [Призыв к действию со ссылкой]`,
},
{
id: 'case',
label: 'Кейс',
Icon: Briefcase,
hint: 'Ситуация → решение → результат',
topicHint: 'Реальный пример из практики',
structure: `[Задача: что было за проблема]
[Что попробовал, что не сработало]
[Что сработало — конкретные шаги]
📊 Результат: [цифры или конкретный итог]
[Вывод — что можно повторить]`,
},
{
id: 'longread',
label: 'Лонгрид',
Icon: BookOpen,
hint: 'Глубокий разбор темы',
topicHint: 'Тема для развёрнутого объяснения',
structure: `[Провокационный или неожиданный тезис]
[Почему стандартный взгляд ошибается]
[Аргумент 1 + пример]
[Аргумент 2 + пример]
[Аргумент 3 + пример]
[Заключение: к чему приходим]`,
},
{
id: 'list',
label: 'Подборка',
Icon: List,
hint: 'N полезных штук',
topicHint: 'Список инструментов, советов, ресурсов',
structure: `[Почему эта подборка полезна]
1. [Название] — [1 предложение почему]
2. [Название] — [1 предложение почему]
3. [Название] — [1 предложение почему]
4. [Название] — [1 предложение почему]
5. [Название] — [1 предложение почему]
[Итог или личная рекомендация #1]`,
},
{
id: 'poll',
label: 'Опрос-разбор',
Icon: HelpCircle,
hint: 'Вопрос → варианты → разбор',
topicHint: 'Дискуссионный вопрос для аудитории',
structure: `[Провокационный вопрос к читателю]
Как бы ты поступил?
А) [Вариант 1]
Б) [Вариант 2]
В) [Вариант 3]
[Мой ответ и почему именно так]
[Приглашение высказаться в комментариях]`,
},
{
id: 'personal',
label: 'Личное',
Icon: User,
hint: 'История → урок → применение',
topicHint: 'Личный опыт или наблюдение',
structure: `[Конкретная ситуация из жизни — детали, дата, место]
[Что почувствовал / что понял в тот момент]
[Урок, который из этого вынес]
[Как это меняет то, что я делаю сейчас]
[Вопрос читателю — было ли у него похожее?]`,
},
];
export default function PostTemplates({ onSelect, disabled }) {
const [open, setOpen] = useState(false);
function pick(tpl) {
onSelect({ label: tpl.label, topicHint: tpl.topicHint, structure: tpl.structure });
setOpen(false);
}
return (
<div className="relative">
<button
onClick={() => setOpen(v => !v)}
disabled={disabled}
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
>
{open ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Шаблоны
</button>
{open && (
<div className="absolute left-0 top-6 z-20 w-72 rounded-xl border border-border bg-surface shadow-xl p-2">
<div className="text-xs font-semibold text-text-mute uppercase tracking-wide px-2 py-1 mb-1">
Выбери структуру поста
</div>
{TEMPLATES.map(tpl => (
<button
key={tpl.id}
onClick={() => pick(tpl)}
className="w-full flex items-start gap-2.5 px-2.5 py-2 rounded-lg hover:bg-surface2 text-left transition-colors"
>
<tpl.Icon className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium text-text">{tpl.label}</div>
<div className="text-xs text-text-mute">{tpl.hint}</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
+348
View File
@@ -0,0 +1,348 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3, Coins } from 'lucide-react';
import AdminBilling from './admin/AdminBilling';
const TABS_SYS = [
{ id: 'settings', label: 'Настройки API' },
{ id: 'billing', label: 'Биллинг' },
];
const CATEGORIES = [
{ slug: 'ai_providers', title: 'AI провайдеры',
hint: 'Ключи, URL и модели для текстовой и картиночной генерации. Меняются на лету — рестарт engine не нужен.' },
{ slug: 'payments', title: 'ЮKassa',
hint: 'Shop ID и Secret Key из личного кабинета ЮKassa. Webhook URL для настройки: https://engine.zeropost.ru/api/billing/webhook' },
{ slug: 'photo_search', title: 'Поиск фото',
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
];
export default function SystemSettings() {
const [sysTab, setSysTab] = useState('settings');
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">
{/* Вкладки системной страницы */}
<div className="flex gap-1 border-b border-border pb-0">
{TABS_SYS.map(t => (
<button key={t.id} onClick={() => setSysTab(t.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
sysTab === t.id ? 'border-accent text-accent' : 'border-transparent text-gray-400 hover:text-gray-200'
}`}>
{t.label}
</button>
))}
</div>
{sysTab === 'billing' && <AdminBilling />}
{sysTab === 'settings' && (<>
<UsageSummary />
{CATEGORIES.map(cat => (
<CategoryBlock
key={cat.slug}
category={cat}
rows={byCategory[cat.slug] || []}
onSaved={() => load()}
/>
))}
</>)}
</div>
);
}
const RANGE_LABELS = [
{ key: 'today', label: 'Сегодня' },
{ key: 'week', label: 'Неделя' },
{ key: 'month', label: 'Месяц' },
{ key: 'all', label: 'Всё' },
];
function UsageSummary() {
const [range, setRange] = useState('today');
const [groupBy, setGroupBy] = useState('service');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
async function load() {
setLoading(true);
setErr('');
try {
const res = await fetch(`/api/admin/usage/summary?range=${range}&group_by=${groupBy}`);
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
setData(await res.json());
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [range, groupBy]);
const fmtRub = v => (Number(v) || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽';
const fmtInt = v => (Number(v) || 0).toLocaleString('ru-RU');
return (
<section className="card p-5">
<div className="flex items-center justify-between flex-wrap gap-2 mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-accent" />
<h2 className="font-semibold">Расход AI</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-md overflow-hidden border border-border">
{RANGE_LABELS.map(r => (
<button
key={r.key}
onClick={() => setRange(r.key)}
className={`px-2.5 py-1 text-xs ${range === r.key ? 'bg-accent text-white' : 'bg-surface2/50 hover:bg-surface2 text-gray-300'}`}
>
{r.label}
</button>
))}
</div>
<select
value={groupBy}
onChange={e => setGroupBy(e.target.value)}
className="input text-xs h-7 py-0"
>
<option value="service">по сервисам</option>
<option value="provider">по провайдерам</option>
<option value="model">по моделям</option>
</select>
<button onClick={load} className="btn-ghost p-1.5" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{err && (
<div className="text-xs text-red-400 mb-3">{err}</div>
)}
{data && (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<Stat label="Сумма" value={fmtRub(data.totals.cost_rub)} accent />
<Stat label="Вызовов" value={fmtInt(data.totals.calls)}
sub={data.totals.failed ? `${data.totals.failed} ошибок` : 'все успешно'} />
<Stat label="Токенов" value={fmtInt(data.totals.prompt_tokens + data.totals.completion_tokens)}
sub={`${fmtInt(data.totals.prompt_tokens)} вход / ${fmtInt(data.totals.completion_tokens)} выход`} />
<Stat label="Картинок" value={fmtInt(data.totals.image_count)}
sub={data.totals.avg_duration_ms ? `сред. ${(data.totals.avg_duration_ms/1000).toFixed(1)}с` : ''} />
</div>
{data.breakdown.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-surface2/50">
<tr className="text-left text-gray-500">
<th className="px-3 py-2 font-medium">
{groupBy === 'service' ? 'Сервис' : groupBy === 'provider' ? 'Провайдер' : 'Модель'}
</th>
<th className="px-3 py-2 font-medium text-right">Вызовов</th>
<th className="px-3 py-2 font-medium text-right">Токены</th>
<th className="px-3 py-2 font-medium text-right">Картинки</th>
<th className="px-3 py-2 font-medium text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{data.breakdown.map(row => (
<tr key={row.key} className="border-t border-border/50">
<td className="px-3 py-2 font-mono">{row.key}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.calls)}{row.failed ? <span className="text-red-400 ml-1">({row.failed} err)</span> : null}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.prompt_tokens + row.completion_tokens)}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.image_count)}</td>
<td className="px-3 py-2 text-right font-mono">{fmtRub(row.cost_rub)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.breakdown.length === 0 && (
<p className="text-sm text-gray-500">За выбранный период вызовов не было.</p>
)}
</>
)}
</section>
);
}
function Stat({ label, value, sub, accent }) {
return (
<div className={`rounded-lg p-3 ${accent ? 'bg-accent/10 border border-accent/30' : 'bg-surface2/50 border border-border'}`}>
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
<div className={`text-lg font-semibold mt-0.5 ${accent ? 'text-accent' : ''}`}>{value}</div>
{sub && <div className="text-[11px] text-gray-500 mt-0.5">{sub}</div>}
</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>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw, Plus, Trash2, Loader2, Lightbulb, X } from 'lucide-react';
export default function TopicBank({ channelId }) {
const [topics, setTopics] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [refilling,setRefilling]= useState(false);
const [newTopic, setNewTopic] = useState('');
const [adding, setAdding] = useState(false);
const [showAdd, setShowAdd] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch(`/api/topics-bank/${channelId}`).then(r => r.json());
setTopics(res.topics || []);
setTotal(res.total_unused || 0);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, [channelId]);
async function refill() {
setRefilling(true);
await fetch(`/api/topics-bank/${channelId}/refill`, { method: 'POST' });
await load();
setRefilling(false);
}
async function addManual() {
if (!newTopic.trim()) return;
setAdding(true);
await fetch(`/api/topics-bank/${channelId}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topics: [newTopic.trim()] }),
});
setNewTopic('');
setShowAdd(false);
await load();
setAdding(false);
}
async function deleteTopic(id) {
await fetch(`/api/topics-bank/item/${id}`, { method: 'DELETE' });
setTopics(t => t.filter(x => x.id !== id));
setTotal(n => Math.max(0, n - 1));
}
const unusedTopics = topics.filter(t => !t.is_used);
const stockColor = total >= 5 ? 'text-green-400' : total > 0 ? 'text-yellow-400' : 'text-red-400';
return (
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-accent" /> Банк тем
</h3>
<p className={`text-xs mt-0.5 ${stockColor}`}>
{total === 0 ? 'Темы закончились — нужно пополнить' : `${total} тем в запасе`}
</p>
</div>
<div className="flex gap-1.5">
<button onClick={() => setShowAdd(v => !v)} className="btn-ghost p-2" title="Добавить тему">
<Plus className="w-4 h-4" />
</button>
<button onClick={refill} disabled={refilling} className="btn-ghost p-2" title="Сгенерировать темы AI">
<RefreshCw className={`w-4 h-4 ${refilling ? 'animate-spin text-accent' : ''}`} />
</button>
</div>
</div>
{/* Добавить вручную */}
{showAdd && (
<div className="flex gap-2">
<input
value={newTopic}
onChange={e => setNewTopic(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addManual()}
placeholder="Введите тему поста..."
className="input flex-1 text-sm py-1.5"
autoFocus
/>
<button onClick={addManual} disabled={adding || !newTopic.trim()} className="btn-primary px-3 py-1.5 text-sm">
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Добавить'}
</button>
<button onClick={() => { setShowAdd(false); setNewTopic(''); }} className="btn-ghost p-2">
<X className="w-4 h-4" />
</button>
</div>
)}
{loading && <div className="py-4 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto text-accent" /></div>}
{!loading && unusedTopics.length === 0 && (
<div className="py-4 text-center text-sm text-gray-500">
Тем нет. Нажмите чтобы сгенерировать AI
</div>
)}
{!loading && unusedTopics.length > 0 && (
<ul className="space-y-1.5 max-h-64 overflow-y-auto">
{unusedTopics.map(t => (
<li key={t.id} className="flex items-start gap-2 text-sm group">
<span className="flex-1 text-gray-300 leading-snug">{t.topic}</span>
<button
onClick={() => deleteTopic(t.id)}
className="opacity-0 group-hover:opacity-100 btn-ghost p-1 shrink-0 text-gray-500 hover:text-red-400 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
</li>
))}
</ul>
)}
<p className="text-xs text-gray-500">
Темы используются при автоматической генерации постов. При запасе &lt;5 AI пополняет автоматически.
</p>
</div>
);
}
+322
View File
@@ -0,0 +1,322 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw, Loader2, Play, Plus, Trash2, Check, ToggleLeft, ToggleRight, BookOpen, Clock, Zap } from 'lucide-react';
const CATEGORY_LABELS = {
'ai-tools': { label: 'AI инструменты', icon: '🤖', color: 'text-purple-400' },
'ai-dev': { label: 'AI разработка', icon: '💻', color: 'text-blue-400' },
'automation': { label: 'Автоматизация', icon: '⚙️', color: 'text-green-400' },
'cybersec': { label: 'Кибербезопасность', icon: '🔒', color: 'text-red-400' },
};
function fmtDate(s) {
if (!s) return '—';
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function nextRunIn(nextRunAt) {
if (!nextRunAt) return null;
const diff = new Date(nextRunAt) - Date.now();
if (diff < 0) return 'скоро';
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
if (h > 0) return `через ${h}ч ${m}м`;
return `через ${m}м`;
}
export default function AdminAutogen() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState({});
const [running, setRunning] = useState({});
const [msg, setMsg] = useState('');
const [drafts, setDrafts] = useState({}); // category → edited settings
// Форма добавления темы в очередь
const [showQueue, setShowQueue] = useState(false);
const [qCat, setQCat] = useState('ai-tools');
const [qTopic, setQTopic] = useState('');
const [qPriority, setQPriority] = useState(5);
const [addingQ, setAddingQ] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/autogen').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
function setDraft(category, field, value) {
setDrafts(d => ({
...d,
[category]: { ...(d[category] || {}), [field]: value },
}));
}
function getSetting(category, field) {
if (drafts[category]?.[field] !== undefined) return drafts[category][field];
const s = data?.settings?.find(s => s.category === category);
return s?.[field];
}
async function save(category) {
const draft = drafts[category];
if (!draft || !Object.keys(draft).length) return;
setSaving(s => ({ ...s, [category]: true }));
try {
const res = await fetch(`/api/admin/autogen/${category}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
}).then(r => r.json());
if (res.ok) {
setMsg(`${category} сохранено`);
setDrafts(d => { const n = {...d}; delete n[category]; return n; });
load();
} else setMsg('Ошибка: ' + res.error);
} catch { setMsg('Ошибка соединения'); }
setSaving(s => ({ ...s, [category]: false }));
setTimeout(() => setMsg(''), 3000);
}
async function runNow(category) {
setRunning(r => ({ ...r, [category]: true }));
const res = await fetch(`/api/admin/autogen/${category}/run`, { method: 'POST' }).then(r => r.json());
setRunning(r => ({ ...r, [category]: false }));
setMsg(res.ok ? `⚡ Генерация ${category} запущена (1-2 мин)` : 'Ошибка: ' + res.error);
setTimeout(() => setMsg(''), 4000);
}
async function addToQueue() {
if (!qTopic.trim()) return;
setAddingQ(true);
const res = await fetch('/api/admin/autogen/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: qCat, topic: qTopic.trim(), priority: qPriority }),
}).then(r => r.json());
setAddingQ(false);
if (res.id) {
setMsg('✓ Тема добавлена в очередь');
setQTopic('');
setShowQueue(false);
load();
} else setMsg('Ошибка: ' + res.error);
setTimeout(() => setMsg(''), 3000);
}
async function removeFromQueue(id) {
await fetch(`/api/admin/autogen/queue/${id}`, { method: 'DELETE' });
load();
}
const categories = data?.settings?.map(s => s.category) || [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Автогенерация блога</h2>
<p className="text-xs text-gray-400 mt-0.5">Статьи для zeropost.ru генерируются автоматически по расписанию</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Категории */}
<div className="space-y-4">
{categories.map(cat => {
const cfg = CATEGORY_LABELS[cat] || { label: cat, icon: '📝', color: 'text-gray-400' };
const s = data.settings.find(s => s.category === cat);
const stat = data.byCategory?.[cat];
const hasDraft = Object.keys(drafts[cat] || {}).length > 0;
const isEnabled = getSetting(cat, 'enabled');
const bankSize = data.topicBankSizes?.[cat] || 0;
return (
<div key={cat} className={`card p-5 ${!isEnabled ? 'opacity-60' : ''}`}>
{/* Заголовок категории */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-xl">{cfg.icon}</span>
<div>
<div className={`font-semibold ${cfg.color}`}>{cfg.label}</div>
<div className="text-xs text-gray-500 font-mono">{cat}</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Запустить сейчас */}
<button onClick={() => runNow(cat)} disabled={running[cat]}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent">
{running[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
Запустить
</button>
{/* Toggle */}
<button onClick={() => {
setDraft(cat, 'enabled', !isEnabled);
setTimeout(() => save(cat), 50);
}}>
{isEnabled
? <ToggleRight className="w-7 h-7 text-green-400" />
: <ToggleLeft className="w-7 h-7 text-gray-500" />}
</button>
</div>
</div>
{/* Статистика */}
<div className="grid grid-cols-3 gap-3 mb-4 text-xs">
<div className="bg-surface2 rounded-lg p-2.5 text-center">
<div className="font-bold text-base">{stat?.cnt_7d || 0}</div>
<div className="text-gray-500 mt-0.5">статей за 7 дней</div>
</div>
<div className="bg-surface2 rounded-lg p-2.5 text-center">
<div className="font-bold text-base">{bankSize}</div>
<div className="text-gray-500 mt-0.5">тем в банке</div>
</div>
<div className="bg-surface2 rounded-lg p-2.5 text-center">
<div className="font-bold text-sm truncate">{nextRunIn(s?.next_run_at) || '—'}</div>
<div className="text-gray-500 mt-0.5">следующий запуск</div>
</div>
</div>
{/* Настройки */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="label text-xs mb-1">Статей в день</label>
<select value={getSetting(cat, 'per_day') ?? 1}
onChange={e => setDraft(cat, 'per_day', +e.target.value)}
className="input w-full text-sm py-1.5">
{[1,2,3,4,5].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="label text-xs mb-1">Час запуска (0-23)</label>
<input type="number" min={0} max={23}
value={getSetting(cat, 'run_hour') ?? 8}
onChange={e => setDraft(cat, 'run_hour', +e.target.value)}
className="input w-full text-sm py-1.5" />
</div>
<div>
<label className="label text-xs mb-1">Минута</label>
<input type="number" min={0} max={59}
value={getSetting(cat, 'run_minute') ?? 0}
onChange={e => setDraft(cat, 'run_minute', +e.target.value)}
className="input w-full text-sm py-1.5" />
</div>
</div>
{/* Последний + следующий */}
<div className="flex gap-4 mt-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Последний запуск: {fmtDate(s?.last_run_at)}
</div>
<div className="flex items-center gap-1">
<Zap className="w-3 h-3" />
Следующий: {fmtDate(s?.next_run_at)}
</div>
</div>
{/* Кнопка сохранить (если есть изменения) */}
{hasDraft && (
<div className="mt-3 pt-3 border-t border-border flex items-center gap-2">
<button onClick={() => save(cat)} disabled={saving[cat]}
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
{saving[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Сохранить
</button>
<button onClick={() => setDrafts(d => { const n = {...d}; delete n[cat]; return n; })}
className="btn-ghost text-sm px-3 py-1.5 text-gray-500">Отмена</button>
</div>
)}
</div>
);
})}
</div>
{/* Очередь тем */}
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium text-sm flex items-center gap-2">
<BookOpen className="w-4 h-4 text-accent" /> Очередь тем
</h3>
<p className="text-xs text-gray-400 mt-0.5">Темы из очереди публикуются раньше тем из банка</p>
</div>
<button onClick={() => setShowQueue(v => !v)}
className="btn-ghost text-sm px-2.5 py-1.5 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Добавить
</button>
</div>
{/* Форма добавления */}
{showQueue && (
<div className="mb-4 p-3 rounded-lg bg-accent/5 border border-accent/20 space-y-2">
<div className="grid grid-cols-3 gap-2">
<select value={qCat} onChange={e => setQCat(e.target.value)} className="input text-sm py-1.5">
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v.icon} {v.label}</option>
))}
</select>
<input value={qTopic} onChange={e => setQTopic(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addToQueue()}
placeholder="Тема статьи..." className="input text-sm py-1.5 col-span-2" autoFocus />
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Приоритет:</label>
<input type="range" min={1} max={10} value={qPriority}
onChange={e => setQPriority(+e.target.value)} className="flex-1" />
<span className="text-xs text-gray-400 w-4">{qPriority}</span>
<button onClick={addToQueue} disabled={addingQ || !qTopic.trim()}
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1">
{addingQ ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Добавить
</button>
<button onClick={() => setShowQueue(false)} className="btn-ghost p-1.5"></button>
</div>
</div>
)}
{/* Список тем в очереди */}
{data.queue?.length === 0 && (
<div className="py-6 text-center text-sm text-gray-500">
Очередь пуста используются темы из банка
</div>
)}
<div className="space-y-2">
{(data.queue || []).map(item => {
const cfg = CATEGORY_LABELS[item.category] || { icon: '📝', color: 'text-gray-400' };
return (
<div key={item.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-surface2 hover:bg-surface2/80">
<span className="text-sm">{cfg.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-200 truncate">{item.topic}</div>
<div className="text-xs text-gray-500 mt-0.5">
{item.category} · приоритет {item.priority} · {fmtDate(item.created_at)}
</div>
</div>
<button onClick={() => removeFromQueue(item.id)}
className="btn-ghost p-1.5 text-gray-500 hover:text-red-400 shrink-0">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
</div>
</>)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Plus, RefreshCw, Search } from 'lucide-react';
const PLAN_BADGE = {
free: 'bg-gray-600 text-gray-200',
starter: 'bg-blue-600 text-white',
pro: 'bg-purple-600 text-white',
business: 'bg-yellow-600 text-black',
};
export default function AdminBillingPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [crediting, setCrediting] = useState(null); // user being credited
const [amount, setAmount] = useState('');
const [desc, setDesc] = useState('');
const [saving, setSaving] = useState(false);
async function load() {
setLoading(true);
const res = await fetch('/api/billing/admin/users').then(r => r.json());
setUsers(Array.isArray(res) ? res : []);
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handleCredit(userId) {
if (!amount || isNaN(parseInt(amount))) return;
setSaving(true);
await fetch('/api/billing/admin/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, amount: parseInt(amount), description: desc || undefined }),
});
setCrediting(null);
setAmount('');
setDesc('');
setSaving(false);
load();
}
const filtered = users.filter(u =>
!search || u.email?.toLowerCase().includes(search.toLowerCase()) || u.name?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Балансы пользователей</h2>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск по email..."
className="input pl-8 py-1.5 text-sm w-48"
/>
</div>
<button onClick={load} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Пользователь</th>
<th className="px-4 py-2.5 text-center">Тариф</th>
<th className="px-4 py-2.5 text-right">Кредиты</th>
<th className="px-4 py-2.5 text-right">Сброс</th>
<th className="px-4 py-2.5 text-center">Действия</th>
</tr>
</thead>
<tbody>
{filtered.map(u => (
<>
<tr key={u.id} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">
<div className="font-medium">{u.name || u.email}</div>
{u.name && <div className="text-xs text-gray-500">{u.email}</div>}
</td>
<td className="px-4 py-2.5 text-center">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${PLAN_BADGE[u.plan_code] || PLAN_BADGE.free}`}>
{u.plan_name || 'Free'}
</span>
</td>
<td className="px-4 py-2.5 text-right font-bold">{u.credits ?? 0}</td>
<td className="px-4 py-2.5 text-right text-xs text-gray-500">
{u.reset_at ? new Date(u.reset_at).toLocaleDateString('ru-RU') : '—'}
</td>
<td className="px-4 py-2.5 text-center">
<button
onClick={() => setCrediting(crediting === u.id ? null : u.id)}
className="btn-ghost px-2 py-1 text-xs flex items-center gap-1 mx-auto"
>
<Plus className="w-3 h-3" /> Начислить
</button>
</td>
</tr>
{crediting === u.id && (
<tr key={`${u.id}-credit`} className="border-t border-accent/20 bg-accent/5">
<td colSpan={5} className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<input
type="number" value={amount} onChange={e => setAmount(e.target.value)}
placeholder="Кол-во кредитов" className="input py-1.5 text-sm w-36"
autoFocus
/>
<input
value={desc} onChange={e => setDesc(e.target.value)}
placeholder="Комментарий (необязательно)" className="input py-1.5 text-sm flex-1 min-w-40"
/>
<button
onClick={() => handleCredit(u.id)}
disabled={saving || !amount}
className="btn-primary py-1.5 px-3 text-sm"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Начислить'}
</button>
<button onClick={() => setCrediting(null)} className="btn-ghost py-1.5 px-3 text-sm">Отмена</button>
</div>
</td>
</tr>
)}
</>
))}
{!filtered.length && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-500">Пользователи не найдены</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
'use client';
import { useState, useEffect } from 'react';
import { Save, Loader2, Check, RefreshCw, Info } from 'lucide-react';
// Метаданные полей для красивого UI
const FIELD_META = {
DEFAULT_POST_LANGUAGE: {
label: 'Язык постов',
desc: 'Применяется к новым каналам',
type: 'select',
options: [
{ v: 'ru', l: '🇷🇺 Русский' },
{ v: 'en', l: '🇬🇧 English' },
{ v: 'auto', l: '🌐 Авто (по нише)' },
],
},
DEFAULT_POST_LENGTH: {
label: 'Длина поста',
desc: 'short ≈ 300-500 зн, medium ≈ 600-1000 зн, long ≈ 1200-2000 зн',
type: 'select',
options: [
{ v: 'short', l: '📏 Короткий (300-500 зн)' },
{ v: 'medium', l: '📄 Средний (600-1000 зн)' },
{ v: 'long', l: '📃 Длинный (1200-2000 зн)' },
],
},
DEFAULT_POST_STYLE: {
label: 'Стиль написания',
desc: 'Тон голоса по умолчанию',
type: 'select',
options: [
{ v: 'informative', l: '📚 Информативный' },
{ v: 'casual', l: '😊 Разговорный' },
{ v: 'professional', l: '👔 Профессиональный' },
{ v: 'storytelling', l: '📖 Сторителлинг' },
{ v: 'provocative', l: '🔥 Провокационный' },
],
},
DEFAULT_POST_GOAL: {
label: 'Цель поста',
desc: 'На что ориентирован контент',
type: 'select',
options: [
{ v: 'educational', l: '🎓 Образовательный' },
{ v: 'entertainment',l: '🎭 Развлекательный' },
{ v: 'sales', l: '💰 Продажи' },
{ v: 'engagement', l: '❤️ Вовлечение' },
{ v: 'news', l: '📰 Новости' },
],
},
DEFAULT_IMAGE_ENABLED: {
label: 'Генерация изображений',
desc: 'Создавать картинки для постов (расходует кредиты)',
type: 'toggle',
},
DEFAULT_EMOJI_ENABLED: {
label: 'Эмодзи в постах',
desc: 'Добавлять эмодзи по умолчанию',
type: 'toggle',
},
DEFAULT_HASHTAGS_IN_POST: {
label: 'Хештеги в постах',
desc: 'Автоматически добавлять хештеги в конец поста',
type: 'toggle',
},
DEFAULT_AUTO_DRAFT_COUNT: {
label: 'Авто-черновиков в день',
desc: 'Для новых каналов с включённой авто-генерацией',
type: 'number',
min: 1, max: 10,
},
DEFAULT_AUTO_DRAFT_TIME: {
label: 'Время генерации черновиков',
desc: 'HH:MM (московское время UTC+3)',
type: 'time',
},
DEFAULT_AI_STYLE_PROMPT: {
label: 'Базовые инструкции стиля',
desc: 'Применяются ко всем каналам поверх индивидуальных настроек',
type: 'textarea',
placeholder: 'Например: Всегда пиши от первого лица. Используй активный залог...',
},
};
const GROUP_ORDER = [
{
title: 'Контент',
keys: ['DEFAULT_POST_LANGUAGE', 'DEFAULT_POST_STYLE', 'DEFAULT_POST_GOAL', 'DEFAULT_POST_LENGTH'],
},
{
title: 'Форматирование',
keys: ['DEFAULT_IMAGE_ENABLED', 'DEFAULT_EMOJI_ENABLED', 'DEFAULT_HASHTAGS_IN_POST'],
},
{
title: 'Авто-черновики',
keys: ['DEFAULT_AUTO_DRAFT_COUNT', 'DEFAULT_AUTO_DRAFT_TIME'],
},
{
title: 'AI-инструкции',
keys: ['DEFAULT_AI_STYLE_PROMPT'],
},
];
export default function AdminContent() {
const [rows, setRows] = useState([]);
const [vals, setVals] = useState({});
const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState({});
const [saved, setSaved] = useState({});
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/settings?category=content').then(r => r.json());
const arr = Array.isArray(res) ? res : [];
setRows(arr);
const v = Object.fromEntries(arr.map(r => [r.key, r.value ?? '']));
setVals(v);
setDirty({});
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
function change(key, val) {
setVals(v => ({ ...v, [key]: val }));
setDirty(d => ({ ...d, [key]: true }));
setSaved(s => ({ ...s, [key]: false }));
}
async function save(key) {
setSaving(s => ({ ...s, [key]: true }));
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: vals[key] }),
}).then(r => r.json());
if (!res.error) {
setDirty(d => ({ ...d, [key]: false }));
setSaved(s => ({ ...s, [key]: true }));
setTimeout(() => setSaved(s => ({ ...s, [key]: false })), 2000);
}
} catch {}
setSaving(s => ({ ...s, [key]: false }));
}
function renderField(key) {
const meta = FIELD_META[key];
if (!meta) return null;
const val = vals[key] ?? '';
const isDirty = dirty[key];
const isSaving = saving[key];
const isSaved = saved[key];
if (meta.type === 'toggle') {
const isOn = val === 'true';
return (
<div key={key} className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div>
<div className="text-sm font-medium">{meta.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
</div>
<button onClick={() => { change(key, isOn ? 'false' : 'true'); setTimeout(() => save(key), 50); }}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isOn ? 'bg-accent' : 'bg-gray-600'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
);
}
if (meta.type === 'select') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<select value={val} onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-full max-w-xs">
{meta.options?.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'number') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<input type="number" min={meta.min} max={meta.max}
value={val} onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-24" />
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'time') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<input type="time" value={val}
onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-32" />
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'textarea') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5 mb-2">{meta.desc}</div>
<textarea rows={3} value={val}
onChange={e => change(key, e.target.value)}
placeholder={meta.placeholder || ''}
className="input w-full resize-none text-sm" />
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="mt-2 btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <span className="mt-1 text-xs text-green-400 flex items-center gap-1"><Check className="w-3 h-3" /> Сохранено</span>}
</div>
);
}
return null;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Настройки контента</h2>
<p className="text-xs text-gray-400 mt-0.5">Дефолты для новых каналов. Существующие каналы не затрагиваются.</p>
</div>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Подсказка */}
<div className="card p-3 border-blue-500/20 bg-blue-500/5 flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-300">
Эти настройки применяются при создании нового канала как стартовые значения.
Каждый канал можно затем настроить индивидуально через вкладку «AI-стиль».
</p>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && GROUP_ORDER.map(group => (
<div key={group.title} className="card p-5">
<h3 className="font-medium text-sm text-gray-400 uppercase tracking-wide mb-1">{group.title}</h3>
{group.keys.map(key => renderField(key))}
</div>
))}
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw, Loader2, AlertTriangle, Cpu, Send, ChevronDown, ChevronUp } from 'lucide-react';
const SOURCE_CONFIG = {
generation: { icon: '⚙️', label: 'Генерация', color: 'text-purple-400', bg: 'bg-purple-500/10' },
ai_provider:{ icon: '🤖', label: 'AI провайдер', color: 'text-blue-400', bg: 'bg-blue-500/10' },
publish: { icon: '📤', label: 'Публикация', color: 'text-orange-400', bg: 'bg-orange-500/10' },
};
// Категоризируем ошибку по тексту
function classifyError(msg) {
if (!msg) return { type: 'unknown', label: 'Неизвестно', color: 'text-gray-400' };
const m = msg.toLowerCase();
if (m.includes('timeout')) return { type: 'timeout', label: 'Таймаут', color: 'text-yellow-400' };
if (m.includes('rate limit')) return { type: 'ratelimit',label: 'Rate limit', color: 'text-orange-400' };
if (m.includes('not supported')) return { type: 'model', label: 'Модель', color: 'text-red-400' };
if (m.includes('empty response')) return { type: 'empty', label: 'Пустой ответ', color: 'text-red-400' };
if (m.includes('network') || m.includes('connect')) return { type: 'network', label: 'Сеть', color: 'text-orange-400' };
if (m.includes('auth') || m.includes('key') || m.includes('401')) return { type: 'auth', label: 'Авторизация', color: 'text-red-400' };
return { type: 'other', label: 'Другое', color: 'text-gray-400' };
}
function timeAgo(s) {
const diff = Date.now() - new Date(s);
if (diff < 60000) return Math.floor(diff / 1000) + 'с назад';
if (diff < 3600000) return Math.floor(diff / 60000) + 'м назад';
if (diff < 86400000)return Math.floor(diff / 3600000) + 'ч назад';
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
}
export default function AdminLogs() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all'); // all | generation | ai_provider | publish
const [expanded, setExpanded] = useState(null);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/logs?limit=100').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
const errors = (data?.errors || []).filter(e =>
filter === 'all' || e.source === filter
);
const counts = (data?.errors || []).reduce((acc, e) => {
acc[e.source] = (acc[e.source] || 0) + 1;
return acc;
}, {});
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Логи ошибок</h2>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !data && (
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
)}
{data && (<>
{/* Топ ошибок */}
{data.topErrors?.length > 0 && (
<div className="card p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Частые ошибки</p>
<div className="space-y-2">
{data.topErrors.map((e, i) => {
const cls = classifyError(e.msg);
const pct = Math.round((e.cnt / data.total) * 100);
return (
<div key={i} className="flex items-center gap-3">
<div className="w-16 text-right text-xs font-mono text-gray-400">{e.cnt}×</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-200 truncate">{e.msg}</div>
<div className="flex items-center gap-2 mt-0.5">
<div className="flex-1 h-1 bg-surface2 rounded-full">
<div className="h-1 bg-accent/60 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs ${cls.color}`}>{cls.label}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Статистика + фильтр */}
<div className="flex items-center gap-2 flex-wrap">
<button onClick={() => setFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${filter === 'all' ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'}`}>
Все ({data.total})
</button>
{Object.entries(SOURCE_CONFIG).map(([k, cfg]) => (
<button key={k} onClick={() => setFilter(k)}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors flex items-center gap-1 ${filter === k ? `${cfg.bg} ${cfg.color} font-medium` : 'text-gray-500 hover:text-gray-300'}`}>
{cfg.icon} {cfg.label} {counts[k] ? `(${counts[k]})` : ''}
</button>
))}
</div>
{/* Список */}
{errors.length === 0 && (
<div className="py-12 text-center text-gray-500">
<AlertTriangle className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">Ошибок не найдено 🎉</div>
</div>
)}
<div className="space-y-2">
{errors.map((err, i) => {
const src = SOURCE_CONFIG[err.source] || SOURCE_CONFIG.generation;
const cls = classifyError(err.message);
const isOpen = expanded === i;
const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'Unknown error';
return (
<div key={i} className={`card border-l-2 overflow-hidden transition-all ${
cls.type === 'timeout' ? 'border-yellow-500/40' :
cls.type === 'auth' ? 'border-red-500/60' :
cls.type === 'model' ? 'border-red-400/40' :
'border-gray-600'
}`}>
<button
onClick={() => setExpanded(isOpen ? null : i)}
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface2/30 transition-colors"
>
<span className="text-base shrink-0 mt-0.5">{src.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className={`text-xs font-medium ${src.color}`}>{src.label}</span>
<span className="text-xs text-gray-500">·</span>
<span className="text-xs text-gray-400 font-mono">{err.operation}</span>
{err.user_email && (
<>
<span className="text-xs text-gray-500">·</span>
<span className="text-xs text-gray-500">{err.user_email}</span>
</>
)}
<span className="text-xs text-gray-600 ml-auto">{timeAgo(err.created_at)}</span>
</div>
<div className="text-sm text-gray-200">{shortMsg}</div>
{err.context && (
<div className="text-xs text-gray-500 mt-0.5 truncate">{err.context}</div>
)}
</div>
<div className="shrink-0 mt-1">
{isOpen
? <ChevronUp className="w-3.5 h-3.5 text-gray-500" />
: <ChevronDown className="w-3.5 h-3.5 text-gray-500" />}
</div>
</button>
{isOpen && (
<div className="px-4 pb-3 border-t border-border bg-surface2/30">
<div className="mt-2 space-y-1.5 text-xs">
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">ID:</span>
<span className="font-mono text-gray-300">{err.entity_id}</span>
</div>
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Источник:</span>
<span className="text-gray-300">{err.source}</span>
</div>
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Операция:</span>
<span className="font-mono text-gray-300">{err.operation}</span>
</div>
{err.context && (
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Контекст:</span>
<span className="text-gray-300 break-all">{err.context}</span>
</div>
)}
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Ошибка:</span>
<span className="text-red-300 break-all font-mono">{err.message}</span>
</div>
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Время:</span>
<span className="text-gray-300">
{new Date(err.created_at).toLocaleString('ru-RU')}
</span>
</div>
<div className="flex gap-2">
<span className="text-gray-500 w-20 shrink-0">Тип ошибки:</span>
<span className={`${cls.color} font-medium`}>{cls.label}</span>
</div>
</div>
{/* Действие для ошибок модели */}
{cls.type === 'model' && err.source === 'ai_provider' && (
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20 text-xs text-red-300">
💡 Проверь настройку AI_IMAGE_MODEL_VIA_RESPONSES в{' '}
<a href="/system?section=settings" className="underline">Настройках API</a>
</div>
)}
{cls.type === 'timeout' && (
<div className="mt-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-xs text-yellow-300">
💡 Таймаут {err.operation?.includes('chat') ? 'текстовой генерации' : 'изображений'} возможны проблемы у провайдера
</div>
)}
</div>
)}
</div>
);
})}
</div>
</>)}
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Trash2, RefreshCw, Loader2, Check, Copy, Tag, ToggleLeft, ToggleRight } from 'lucide-react';
function randomCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
return Array.from({length:8}, () => chars[Math.floor(Math.random()*chars.length)]).join('');
}
function fmtDate(s) {
if (!s) return '∞';
return new Date(s).toLocaleDateString('ru-RU');
}
export default function AdminPromos() {
const [promos, setPromos] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [showForm,setShowForm]= useState(false);
// Form
const [code, setCode] = useState(randomCode());
const [type, setType] = useState('credits');
const [value, setValue] = useState('100');
const [maxUses, setMaxUses] = useState('1');
const [expiresAt, setExpiresAt]= useState('');
const [desc, setDesc] = useState('');
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/promos').then(r => r.json());
setPromos(Array.isArray(res) ? res : []);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function create() {
if (!code || !value) return;
setSaving(true);
try {
const res = await fetch('/api/admin/promos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code, type, value: parseInt(value),
max_uses: parseInt(maxUses) || 1,
expires_at: expiresAt || null,
description: desc || null,
}),
}).then(r => r.json());
if (res.error) { setMsg('Ошибка: ' + res.error); }
else {
setMsg('Промокод создан ✓');
setShowForm(false);
setCode(randomCode());
setValue('100'); setMaxUses('1'); setExpiresAt(''); setDesc('');
load();
}
} catch { setMsg('Ошибка соединения'); }
setSaving(false);
setTimeout(() => setMsg(''), 3000);
}
async function toggleActive(promo) {
await fetch(`/api/admin/promos/${promo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !promo.is_active }),
});
load();
}
async function deletePromo(id) {
if (!confirm('Удалить промокод?')) return;
await fetch(`/api/admin/promos/${id}`, { method: 'DELETE' });
load();
}
function copyCode(code) {
navigator.clipboard.writeText(code).catch(() => {});
setMsg('Скопировано: ' + code);
setTimeout(() => setMsg(''), 2000);
}
const TYPE_LABELS = { credits: '🎁 Кредиты', discount_pct: '% Скидка' };
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Промокоды</h2>
<div className="flex gap-2">
{msg && <span className="text-sm text-green-400 self-center">{msg}</span>}
<button onClick={() => load()} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
<button onClick={() => setShowForm(v => !v)} className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Создать
</button>
</div>
</div>
{/* Форма создания */}
{showForm && (
<div className="card p-5 border-accent/30 bg-accent/5 space-y-4">
<h3 className="font-medium text-sm">Новый промокод</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label text-xs mb-1">Код</label>
<div className="flex gap-1">
<input value={code} onChange={e => setCode(e.target.value.toUpperCase())}
className="input flex-1 font-mono text-sm py-1.5" maxLength={32} />
<button onClick={() => setCode(randomCode())} className="btn-ghost p-2 text-xs" title="Генерировать"></button>
</div>
</div>
<div>
<label className="label text-xs mb-1">Тип</label>
<select value={type} onChange={e => setType(e.target.value)} className="input w-full text-sm py-1.5">
<option value="credits">🎁 Бонусные кредиты</option>
<option value="discount_pct">% Скидка на подписку</option>
</select>
</div>
<div>
<label className="label text-xs mb-1">{type === 'credits' ? 'Кредитов' : 'Скидка %'}</label>
<input type="number" value={value} onChange={e => setValue(e.target.value)}
className="input w-full text-sm py-1.5" min={1} max={type === 'discount_pct' ? 100 : 10000} />
</div>
<div>
<label className="label text-xs mb-1">Макс. использований (-1 = )</label>
<input type="number" value={maxUses} onChange={e => setMaxUses(e.target.value)}
className="input w-full text-sm py-1.5" min={-1} />
</div>
<div>
<label className="label text-xs mb-1">Истекает (необязательно)</label>
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
className="input w-full text-sm py-1.5" />
</div>
<div>
<label className="label text-xs mb-1">Описание</label>
<input value={desc} onChange={e => setDesc(e.target.value)}
placeholder="Для партнёров..." className="input w-full text-sm py-1.5" />
</div>
</div>
<div className="flex gap-2">
<button onClick={create} disabled={saving || !code || !value}
className="btn-primary px-4 py-2 text-sm flex items-center gap-1.5">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Создать
</button>
<button onClick={() => setShowForm(false)} className="btn-ghost px-4 py-2 text-sm">Отмена</button>
</div>
</div>
)}
{/* Список */}
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && promos.length === 0 && (
<div className="py-12 text-center text-gray-500 text-sm">
<Tag className="w-10 h-10 mx-auto mb-3 opacity-20" />
Промокодов пока нет
</div>
)}
{!loading && promos.length > 0 && (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Код</th>
<th className="px-4 py-2.5 text-center">Тип / Ценность</th>
<th className="px-4 py-2.5 text-center">Использован</th>
<th className="px-4 py-2.5 text-center">Истекает</th>
<th className="px-4 py-2.5 text-center">Статус</th>
<th className="px-4 py-2.5 text-center">Действия</th>
</tr>
</thead>
<tbody>
{promos.map(p => (
<tr key={p.id} className={`border-t border-border ${!p.is_active ? 'opacity-50' : ''} hover:bg-surface2/50`}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<code className="font-mono font-bold text-accent">{p.code}</code>
<button onClick={() => copyCode(p.code)} className="btn-ghost p-1" title="Скопировать">
<Copy className="w-3 h-3" />
</button>
</div>
{p.description && <div className="text-xs text-gray-500 mt-0.5">{p.description}</div>}
</td>
<td className="px-4 py-2.5 text-center">
<div className="text-xs text-gray-400">{TYPE_LABELS[p.type]}</div>
<div className="font-bold">{p.type === 'credits' ? `+${p.value} кр` : `${p.value}%`}</div>
</td>
<td className="px-4 py-2.5 text-center">
<span className={p.uses_real >= p.max_uses && p.max_uses !== -1 ? 'text-red-400' : ''}>
{p.uses_real} / {p.max_uses === -1 ? '∞' : p.max_uses}
</span>
</td>
<td className="px-4 py-2.5 text-center text-xs text-gray-400">{fmtDate(p.expires_at)}</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => toggleActive(p)}>
{p.is_active
? <ToggleRight className="w-5 h-5 text-green-400 mx-auto" />
: <ToggleLeft className="w-5 h-5 text-gray-500 mx-auto" />}
</button>
</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => deletePromo(p.id)}
className="btn-ghost p-1.5 text-gray-500 hover:text-red-400 mx-auto">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw, Loader2, RotateCcw, Trash2, AlertTriangle, CheckCircle, Clock, XCircle, Zap } from 'lucide-react';
const STATUS_CONFIG = {
done: { icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-500/10', label: 'Готово' },
processing: { icon: Clock, color: 'text-yellow-400', bg: 'bg-yellow-500/10', label: 'В процессе' },
pending: { icon: Zap, color: 'text-blue-400', bg: 'bg-blue-500/10', label: 'В очереди' },
failed: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10', label: 'Ошибка' },
};
const TYPE_ICONS = { post: '✍️', article: '📝', topics: '💡' };
function timeAgo(s) {
const diff = Date.now() - new Date(s);
if (diff < 60000) return Math.floor(diff/1000) + 'с';
if (diff < 3600000) return Math.floor(diff/60000) + 'м';
if (diff < 86400000) return Math.floor(diff/3600000) + 'ч';
return new Date(s).toLocaleDateString('ru-RU');
}
export default function AdminQueue() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState({});
const [filter, setFilter] = useState('all');
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/queue').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function retry(id) {
setBusy(b => ({ ...b, [id]: 'retry' }));
const res = await fetch(`/api/admin/queue/${id}/retry`, { method: 'POST' }).then(r => r.json());
setBusy(b => ({ ...b, [id]: null }));
setMsg(res.ok ? '✓ Задача добавлена в очередь' : 'Ошибка: ' + res.error);
setTimeout(() => setMsg(''), 3000);
load();
}
async function clearStuck() {
setBusy(b => ({ ...b, stuck: true }));
const res = await fetch('/api/admin/queue/stuck', { method: 'DELETE' }).then(r => r.json());
setBusy(b => ({ ...b, stuck: false }));
setMsg(`✓ Сброшено застрявших: ${res.cleared}`);
setTimeout(() => setMsg(''), 3000);
load();
}
const stats = data?.stats || [];
const stuck = data?.stuck || [];
const jobs = (data?.recent || []).filter(j => filter === 'all' || j.status === filter);
const totals = Object.fromEntries(stats.map(s => [s.status, s]));
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="font-semibold">Очередь генерации</h2>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
{stuck.length > 0 && (
<button onClick={clearStuck} disabled={busy.stuck}
className="btn-ghost text-sm px-3 py-1.5 text-orange-400 flex items-center gap-1.5">
{busy.stuck ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Сбросить {stuck.length} застрявших
</button>
)}
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Статистика */}
<div className="grid grid-cols-4 gap-3">
{['done','processing','pending','failed'].map(s => {
const cfg = STATUS_CONFIG[s];
const stat = totals[s];
const Icon = cfg.icon;
return (
<div key={s} className={`card p-3 ${cfg.bg}`}>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${cfg.color}`} />
<span className="text-xs text-gray-400">{cfg.label}</span>
</div>
<div className="text-2xl font-bold">{stat?.cnt || 0}</div>
{stat?.avg_sec && (
<div className="text-xs text-gray-500 mt-0.5">ср. {stat.avg_sec}с</div>
)}
</div>
);
})}
</div>
{/* Застрявшие alert */}
{stuck.length > 0 && (
<div className="card p-3 border-orange-500/30 bg-orange-500/5 flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
<div>
<div className="font-medium text-sm">{stuck.length} задач застряли (processing &gt; 5 мин)</div>
<div className="text-xs text-gray-400">
{stuck.map(j => `#${j.id} ${j.type}`).join(', ')}
</div>
</div>
</div>
)}
{/* Фильтр */}
<div className="flex gap-1">
{[['all','Все'], ...Object.entries(STATUS_CONFIG).map(([k,v]) => [k, v.label])].map(([v,l]) => (
<button key={v} onClick={() => setFilter(v)}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${
filter === v ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'
}`}>
{l}
{v !== 'all' && totals[v] && <span className="ml-1 opacity-60">({totals[v].cnt})</span>}
</button>
))}
</div>
{/* Список задач */}
<div className="card overflow-hidden">
{jobs.length === 0 && (
<div className="py-8 text-center text-gray-500 text-sm">Задач не найдено</div>
)}
{jobs.map(job => {
const cfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.pending;
const Icon = cfg.icon;
return (
<div key={job.id} className="flex items-start gap-3 px-4 py-3 border-b border-border last:border-0 hover:bg-surface2/50">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${cfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs">{TYPE_ICONS[job.type] || '⚙️'}</span>
<span className="text-xs text-gray-400 font-medium">{job.type}</span>
<span className="text-xs text-gray-600">#{job.id}</span>
{job.channel_name && <span className="text-xs text-gray-500">· {job.channel_name}</span>}
{job.user_email && <span className="text-xs text-gray-600">· {job.user_email}</span>}
</div>
{job.topic && (
<div className="text-sm text-gray-200 truncate">{job.topic}</div>
)}
{job.error && (
<div className="text-xs text-red-400 mt-0.5 truncate">{job.error}</div>
)}
{(job.tokens_in || job.tokens_out) && (
<div className="text-xs text-gray-600 mt-0.5">
{job.tokens_in ? `${job.tokens_in}` : ''} {job.tokens_out ? `${job.tokens_out}` : ''} токенов
</div>
)}
</div>
<div className="text-right shrink-0">
<div className="text-xs text-gray-500">{timeAgo(job.created_at)}</div>
{job.status === 'failed' && (
<button onClick={() => retry(job.id)} disabled={!!busy[job.id]}
className="mt-1 btn-ghost p-1 text-xs flex items-center gap-1 text-gray-400 hover:text-accent">
{busy[job.id] === 'retry' ? <Loader2 className="w-3 h-3 animate-spin" /> : <RotateCcw className="w-3 h-3" />}
Retry
</button>
)}
</div>
</div>
);
})}
</div>
</>)}
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Trash2, Loader2, RefreshCw, Zap, Check, ChevronDown, ChevronRight } from 'lucide-react';
const CATEGORY_META = {
'ai-tools': { label: 'AI инструменты', icon: '🤖', color: 'text-purple-400' },
'ai-dev': { label: 'AI разработка', icon: '💻', color: 'text-blue-400' },
'automation': { label: 'Автоматизация', icon: '⚙️', color: 'text-green-400' },
'cybersec': { label: 'Кибербезопасность', icon: '🔒', color: 'text-red-400' },
};
export default function AdminTopicBank() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState({}); // cat → expanded
const [msg, setMsg] = useState('');
const [gen, setGen] = useState({}); // cat → generating
// Форма добавления
const [addCat, setAddCat] = useState('ai-tools');
const [addText, setAddText] = useState('');
const [adding, setAdding] = useState(false);
const [showAdd, setShowAdd] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/blog-topics?includeUsed=true&limit=200').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function addTopic() {
if (!addText.trim()) return;
setAdding(true);
const lines = addText.split('\n').map(l => l.trim()).filter(Boolean);
let added = 0;
for (const topic of lines) {
const res = await fetch('/api/admin/blog-topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: addCat, topic }),
}).then(r => r.json());
if (res.id) added++;
}
setMsg(`✓ Добавлено ${added} тем`);
setAddText(''); setShowAdd(false);
load();
setAdding(false);
setTimeout(() => setMsg(''), 2000);
}
async function deleteTopic(id) {
await fetch(`/api/admin/blog-topics/${id}`, { method: 'DELETE' });
load();
}
async function generate(category) {
setGen(g => ({ ...g, [category]: true }));
await fetch('/api/admin/blog-topics/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, count: 10 }),
});
setMsg(`⚡ Генерирую 10 тем для ${category} (~30с)`);
setTimeout(() => { load(); setMsg(''); }, 35000);
setTimeout(() => setGen(g => ({ ...g, [category]: false })), 35000);
}
const byCategory = {};
for (const t of data?.topics || []) {
if (!byCategory[t.category]) byCategory[t.category] = [];
byCategory[t.category].push(t);
}
const stats = Object.fromEntries((data?.stats || []).map(s => [s.category, s]));
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Банк тем блога</h2>
<p className="text-xs text-gray-400 mt-0.5">Темы для автогенерации статей на zeropost.ru</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
<button onClick={() => setShowAdd(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Добавить
</button>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Форма добавления */}
{showAdd && (
<div className="card p-4 border-accent/30 bg-accent/5 space-y-3">
<h3 className="font-medium text-sm">Добавить темы</h3>
<div className="flex gap-2">
<select value={addCat} onChange={e => setAddCat(e.target.value)} className="input text-sm py-1.5 w-48">
{Object.entries(CATEGORY_META).map(([k, v]) => (
<option key={k} value={k}>{v.icon} {v.label}</option>
))}
</select>
</div>
<textarea rows={4} value={addText} onChange={e => setAddText(e.target.value)}
placeholder={"Одна тема на строку:\nКак использовать Claude API в продакшене\nTop 10 AI инструментов для разработчиков"}
className="input w-full text-sm resize-none" autoFocus />
<div className="flex gap-2">
<button onClick={addTopic} disabled={adding || !addText.trim()}
className="btn-primary px-4 py-1.5 text-sm flex items-center gap-1.5">
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Добавить
</button>
<button onClick={() => { setShowAdd(false); setAddText(''); }}
className="btn-ghost px-3 py-1.5 text-sm">Отмена</button>
</div>
</div>
)}
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{/* Категории */}
{Object.entries(CATEGORY_META).map(([cat, cfg]) => {
const topics = byCategory[cat] || [];
const stat = stats[cat] || { total: 0, unused: 0 };
const isOpen = open[cat];
const unused = topics.filter(t => !t.is_published);
const used = topics.filter(t => t.is_published);
return (
<div key={cat} className="card overflow-hidden">
{/* Header */}
<button onClick={() => setOpen(o => ({ ...o, [cat]: !isOpen }))}
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-surface2/30 transition-colors">
<span className="text-xl">{cfg.icon}</span>
<div className="flex-1 text-left">
<div className={`font-medium ${cfg.color}`}>{cfg.label}</div>
<div className="text-xs text-gray-500 mt-0.5">
{stat.unused} неиспользованных · {used.length} уже опубликованы · итого {stat.total}
</div>
</div>
{/* Прогресс-бар использования */}
<div className="w-24">
<div className="h-1.5 bg-surface2 rounded-full">
<div className={`h-1.5 rounded-full ${cfg.color.replace('text-','bg-')}`}
style={{ width: `${stat.total ? Math.round((used.length/stat.total)*100) : 0}%` }} />
</div>
<div className="text-xs text-gray-500 mt-1 text-right">
{stat.total ? Math.round((used.length/stat.total)*100) : 0}%
</div>
</div>
<button onClick={e => { e.stopPropagation(); generate(cat); }} disabled={gen[cat]}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent shrink-0 ml-2">
{gen[cat] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
+10 AI
</button>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />}
</button>
{/* Список тем */}
{isOpen && (
<div className="border-t border-border">
{/* Неиспользованные */}
{unused.length > 0 && (
<div className="px-5 py-3">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Не использованы ({unused.length})
</div>
<div className="space-y-1">
{unused.map(t => (
<div key={t.id} className="flex items-center gap-2 group py-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-accent/40 shrink-0" />
<span className="text-sm flex-1">{t.topic}</span>
<span className="text-xs text-gray-600">{t.source}</span>
<button onClick={() => deleteTopic(t.id)}
className="opacity-0 group-hover:opacity-100 btn-ghost p-1 text-gray-500 hover:text-red-400">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Использованные */}
{used.length > 0 && (
<div className="px-5 py-3 border-t border-border/50">
<div className="text-xs text-gray-600 uppercase tracking-wide mb-2">
Опубликованы ({used.length})
</div>
<div className="space-y-1 opacity-50">
{used.map(t => (
<div key={t.id} className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500/40 shrink-0" />
<span className="text-sm line-through">{t.topic}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
+333
View File
@@ -0,0 +1,333 @@
'use client';
import { useState, useEffect } from 'react';
import { Search, RefreshCw, Loader2, Plus, ChevronRight, X, Check,
Ban, Unlock, CreditCard, User, ArrowLeft } from 'lucide-react';
const PLAN_BADGE = {
free: 'bg-gray-600 text-gray-200',
starter: 'bg-blue-600 text-white',
pro: 'bg-purple-600 text-white',
business: 'bg-yellow-600 text-black',
};
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
const TX_LABELS = {
spend_image: { icon: '🖼', color: 'text-red-400' },
spend_text_post: { icon: '✍️', color: 'text-red-400' },
spend_article: { icon: '📝', color: 'text-red-400' },
plan_credit: { icon: '🎁', color: 'text-green-400' },
topup: { icon: '💳', color: 'text-green-400' },
bonus: { icon: '⭐', color: 'text-blue-400' },
};
export default function AdminUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [selected, setSelected]= useState(null); // детальный просмотр
const [detail, setDetail] = useState(null);
const [detailLoading, setDL] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
// Credit form
const [creditAmount, setCreditAmount] = useState('');
const [creditDesc, setCreditDesc] = useState('');
const [showCredit, setShowCredit] = useState(false);
// Plan change
const [planOptions, setPlanOptions] = useState([]);
const [showPlan, setShowPlan] = useState(false);
const [newPlan, setNewPlan] = useState('');
async function loadUsers() {
setLoading(true);
try {
const [usersRes, plansRes] = await Promise.all([
fetch('/api/admin/users').then(r => r.json()),
fetch('/api/billing/plans').then(r => r.json()),
]);
setUsers(Array.isArray(usersRes) ? usersRes : []);
setPlanOptions(plansRes.plans || []);
} catch {}
setLoading(false);
}
async function loadDetail(id) {
setDL(true);
try {
const res = await fetch(`/api/admin/users/${id}`).then(r => r.json());
setDetail(res);
} catch {}
setDL(false);
}
useEffect(() => { loadUsers(); }, []);
async function toggleBlock(userId, currentBlocked) {
setSaving(true);
await fetch(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_blocked: !currentBlocked }),
});
setMsg(currentBlocked ? 'Разблокирован' : 'Заблокирован');
setTimeout(() => setMsg(''), 2000);
loadUsers();
if (detail) loadDetail(userId);
setSaving(false);
}
async function applyCredit(userId) {
if (!creditAmount) return;
setSaving(true);
await fetch('/api/admin/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, amount: parseInt(creditAmount), description: creditDesc || undefined }),
});
setMsg(`+${creditAmount} кредитов начислено`);
setTimeout(() => setMsg(''), 2000);
setCreditAmount(''); setCreditDesc(''); setShowCredit(false);
loadUsers();
if (detail) loadDetail(userId);
setSaving(false);
}
async function applyPlan(userId) {
if (!newPlan) return;
setSaving(true);
await fetch(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_code: newPlan }),
});
setMsg('План изменён');
setTimeout(() => setMsg(''), 2000);
setShowPlan(false); setNewPlan('');
loadUsers();
if (detail) loadDetail(userId);
setSaving(false);
}
const filtered = users.filter(u =>
!search || u.email?.toLowerCase().includes(search.toLowerCase()) || u.name?.toLowerCase().includes(search.toLowerCase())
);
// ── Детальная страница пользователя ──
if (selected && detail) {
const u = detail.user;
return (
<div className="space-y-5">
<div className="flex items-center gap-3">
<button onClick={() => { setSelected(null); setDetail(null); setShowCredit(false); setShowPlan(false); }}
className="btn-ghost flex items-center gap-1.5 text-sm">
<ArrowLeft className="w-4 h-4" /> Все пользователи
</button>
{msg && <span className="text-sm text-green-400">{msg}</span>}
</div>
{/* Профиль */}
<div className="card p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
<User className="w-5 h-5 text-accent" />
</div>
<div>
<div className="font-semibold">{u.name || u.email}</div>
{u.name && <div className="text-sm text-gray-400">{u.email}</div>}
<div className="text-xs text-gray-500 mt-0.5">
Зарегистрирован: {new Date(u.created_at).toLocaleDateString('ru-RU')}
{u.is_admin && ' · 👑 Admin'}
{u.is_blocked && ' · 🚫 Заблокирован'}
</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setShowCredit(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" /> Кредиты
</button>
<button onClick={() => setShowPlan(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<CreditCard className="w-3.5 h-3.5" /> Тариф
</button>
<button onClick={() => toggleBlock(u.id, u.is_blocked)} disabled={saving}
className={`text-sm px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors ${
u.is_blocked ? 'bg-green-600/20 text-green-400 hover:bg-green-600/30' : 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
}`}>
{u.is_blocked ? <><Unlock className="w-3.5 h-3.5" /> Разблокировать</> : <><Ban className="w-3.5 h-3.5" /> Заблокировать</>}
</button>
</div>
</div>
{/* Форма начисления кредитов */}
{showCredit && (
<div className="mt-4 p-3 rounded-lg bg-accent/5 border border-accent/20 flex items-center gap-2 flex-wrap">
<input type="number" value={creditAmount} onChange={e => setCreditAmount(e.target.value)}
placeholder="Кол-во" className="input py-1.5 text-sm w-24" autoFocus />
<input value={creditDesc} onChange={e => setCreditDesc(e.target.value)}
placeholder="Комментарий" className="input py-1.5 text-sm flex-1 min-w-40" />
<button onClick={() => applyCredit(u.id)} disabled={saving || !creditAmount}
className="btn-primary py-1.5 px-3 text-sm">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Начислить'}
</button>
<button onClick={() => setShowCredit(false)} className="btn-ghost p-1.5"><X className="w-4 h-4" /></button>
</div>
)}
{/* Форма смены тарифа */}
{showPlan && (
<div className="mt-4 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20 flex items-center gap-2 flex-wrap">
<select value={newPlan} onChange={e => setNewPlan(e.target.value)}
className="input py-1.5 text-sm flex-1">
<option value="">Выберите тариф...</option>
{planOptions.map(p => <option key={p.code} value={p.code}>{p.name} {p.price_rub}/мес</option>)}
</select>
<button onClick={() => applyPlan(u.id)} disabled={saving || !newPlan}
className="btn-primary py-1.5 px-3 text-sm">Применить</button>
<button onClick={() => setShowPlan(false)} className="btn-ghost p-1.5"><X className="w-4 h-4" /></button>
</div>
)}
</div>
{/* Баланс */}
{detail.balance && (
<div className="grid grid-cols-3 gap-3">
<div className="card p-3 text-center border-accent/30">
<div className="text-2xl font-bold text-accent">{detail.balance.credits ?? 0}</div>
<div className="text-xs text-gray-400 mt-0.5">кредитов</div>
</div>
<div className="card p-3 text-center">
<div className={`text-sm font-bold px-2 py-0.5 rounded inline-block ${PLAN_BADGE[detail.balance.plan_code] || 'bg-gray-600 text-gray-200'}`}>
{detail.balance.plan_name || 'Free'}
</div>
<div className="text-xs text-gray-400 mt-1">тариф</div>
</div>
<div className="card p-3 text-center">
<div className="text-sm font-medium">{detail.balance.reset_at ? new Date(detail.balance.reset_at).toLocaleDateString('ru-RU') : '—'}</div>
<div className="text-xs text-gray-400 mt-0.5">сброс кредитов</div>
</div>
</div>
)}
{/* Каналы */}
{detail.channels.length > 0 && (
<div className="card p-4">
<h3 className="font-medium text-sm mb-3">Каналы ({detail.channels.length})</h3>
<div className="space-y-2">
{detail.channels.map(ch => (
<div key={ch.id} className="flex items-center gap-2 text-sm">
<span>{PLATFORM_ICONS[ch.platform] || '📢'}</span>
<span className="font-medium">{ch.name}</span>
{ch.tg_username && <span className="text-gray-500 text-xs">@{ch.tg_username}</span>}
<span className={`ml-auto text-xs px-1.5 py-0.5 rounded ${ch.is_active ? 'bg-green-500/20 text-green-400' : 'bg-gray-600 text-gray-400'}`}>
{ch.is_active ? 'активен' : 'выкл'}
</span>
</div>
))}
</div>
</div>
)}
{/* История транзакций */}
{detail.transactions.length > 0 && (
<div className="card overflow-hidden">
<div className="px-4 py-3 bg-surface2 text-sm font-medium">История транзакций</div>
{detail.transactions.map(tx => {
const meta = TX_LABELS[tx.type] || { icon: '💬', color: 'text-gray-400' };
return (
<div key={tx.id} className="flex items-center justify-between px-4 py-2.5 border-t border-border text-sm hover:bg-surface2/50">
<div className="flex items-center gap-2">
<span>{meta.icon}</span>
<span className="text-gray-300">{tx.description || tx.type}</span>
</div>
<div className="text-right">
<div className={`font-medium ${meta.color}`}>{tx.amount > 0 ? '+' : ''}{tx.amount} кр</div>
<div className="text-xs text-gray-500">{new Date(tx.created_at).toLocaleDateString('ru-RU')}</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
if (selected && detailLoading) {
return <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>;
}
// ── Список пользователей ──
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Пользователи</h2>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск по email..."
className="input pl-8 py-1.5 text-sm w-52" />
</div>
<button onClick={loadUsers} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
</div>
{msg && <div className="text-sm text-green-400">{msg}</div>}
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Пользователь</th>
<th className="px-4 py-2.5 text-center">Тариф</th>
<th className="px-4 py-2.5 text-right">Кредиты</th>
<th className="px-4 py-2.5 text-center">Статус</th>
<th className="px-4 py-2.5 text-right">Зарегистрирован</th>
<th className="px-4 py-2.5 text-center">Действия</th>
</tr>
</thead>
<tbody>
{filtered.map(u => (
<tr key={u.id} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">
<div className="font-medium">{u.name || u.email}</div>
{u.name && <div className="text-xs text-gray-500">{u.email}</div>}
{u.is_admin && <span className="text-xs text-yellow-400">👑 admin</span>}
</td>
<td className="px-4 py-2.5 text-center">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${PLAN_BADGE[u.plan_code] || 'bg-gray-600 text-gray-200'}`}>
{u.plan_name || 'Free'}
</span>
</td>
<td className="px-4 py-2.5 text-right font-bold">{u.credits ?? 0}</td>
<td className="px-4 py-2.5 text-center">
{u.is_blocked
? <span className="text-xs text-red-400">🚫 Блок</span>
: <span className="text-xs text-green-400"> Активен</span>}
</td>
<td className="px-4 py-2.5 text-right text-xs text-gray-500">
{new Date(u.created_at).toLocaleDateString('ru-RU')}
</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => { setSelected(u.id); loadDetail(u.id); }}
className="btn-ghost px-2 py-1 text-xs flex items-center gap-1 mx-auto">
Открыть <ChevronRight className="w-3 h-3" />
</button>
</td>
</tr>
))}
{!filtered.length && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">Пользователи не найдены</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+66 -2
View File
@@ -1,7 +1,7 @@
/** /**
* Engine client — единая точка вызовов к zeropost-engine * Engine client — единая точка вызовов к zeropost-engine
*/ */
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3040'; const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
async function call(path, options = {}) { async function call(path, options = {}) {
@@ -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,65 @@ 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' }),
// AI usage (admin)
usageSummary: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/usage/summary${qs ? '?' + qs : ''}`);
},
usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`),
// Billing
getBillingBalance: (userId) => call('/api/billing/balance', { userId }),
getBillingPlans: () => fetch('/api/billing/plans', { cache: 'no-store' }).then(r => r.json()),
getTransactions: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/billing/transactions?${qs}`);
},
adminCreditUser: (data) => call('/api/billing/admin/credit', { method: 'POST', body: data }),
adminGetBalances: () => call('/api/billing/admin/users'),
// Editor notes
listNotes: () => call('/api/notes?limit=100'),
createNote: (data) => call('/api/notes', { method: 'POST', body: data }),
updateNote: (id, data) => call(`/api/notes/${id}`, { method: 'PATCH', body: data }),
deleteNote: (id) => call(`/api/notes/${id}`, { method: 'DELETE' }),
// Calendar
getCalendar: (userId, params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/calendar${qs ? '?' + qs : ''}`, { userId });
},
// Metrics
getChannelMetrics: (channelId, params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/metrics/channel/${channelId}${qs ? '?' + qs : ''}`);
},
getBestTime: (channelId, params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/metrics/best-time/${channelId}${qs ? '?' + qs : ''}`);
},
getUserPostMetrics: (userId, channelId, params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/metrics/user-posts/${channelId}${qs ? '?' + qs : ''}`, { userId });
},
collectMetrics: () => call('/api/metrics/collect', { method: 'POST' }),
generateFromUrl: (userId, data) => call('/api/generate/from-url', { userId, method: 'POST', body: data }),
updateUserPostSchedule: (userId, id, scheduledAt) =>
call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: { scheduled_at: scheduledAt } }),
}; };
// Добавляем в конец файла перед module.exports или в общий объект
+7 -1
View File
@@ -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;
} }