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 { 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]
);
const user = rows[0];
const s = await getSession();
s.userId = user.id;
s.email = user.email;
s.isAdmin = !!user.is_admin;
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
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) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
@@ -41,6 +55,10 @@ export async function POST(req) {
s.userId = user.id;
s.email = user.email;
s.name = user.name;
s.isAdmin = !!user.is_admin;
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);
return NextResponse.json(channel);
} 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 [niche, setNiche] = 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');
// Шаг 2 — стиль
@@ -70,7 +71,7 @@ export default function NewChannelPage() {
setBusy(true);
setError('');
const data = {
name, niche, audience, goal, language, region: 'ru',
name, niche, audience, goal: goals.join(','), language, region: 'ru',
style: {
tone, formality, humor,
post_length: postLength,
@@ -88,7 +89,16 @@ export default function NewChannelPage() {
});
const json = await res.json();
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}`);
}
@@ -150,22 +160,67 @@ export default function NewChannelPage() {
/>
</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">
{GOALS.map(g => (
<button
key={g.v}
type="button"
onClick={() => setGoal(g.v)}
className={`p-2.5 rounded-lg border text-left transition-colors ${
goal === g.v ? '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>
))}
{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 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>
<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 || 'Ошибка');
return;
}
router.push('/');
// Новый пользователь → онбординг, существующий → главная
router.push(data.isNew ? '/onboarding' : '/');
}
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() {
const user = await requireUser();
if (!user) redirect('/login');
if (!user) redirect('/landing');
let channels = [];
try {
@@ -43,50 +43,47 @@ export default async function HomePage() {
{channels.length === 0 ? (
<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-7 h-7 text-gray-500" />
</div>
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
<p className="text-sm text-gray-500 mb-6">
Создай первый канал, чтобы начать генерировать посты
</p>
<MessageSquare className="w-12 h-12 mx-auto mb-4 text-accent opacity-50" />
<h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
<p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
<Link href="/channels/new" className="btn-primary">
<Plus className="w-4 h-4" />
Создать канал
Создать первый канал
</Link>
</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 => (
<Link
key={ch.id}
href={`/channels/${ch.id}`}
className="card p-5 hover:border-accent/40 transition-colors group"
>
<Link 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">
<h3 className="font-semibold group-hover:text-accent transition-colors">
{ch.name}
</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
{GOAL_LABELS[ch.goal] || ch.goal}
<div>
<h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
{ch.tg_username && (
<span className="text-xs text-gray-500">@{ch.tg_username}</span>
)}
</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>
</div>
{ch.niche && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
{ch.niche}
</p>
<p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
{ch.audience && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{ch.goal && (
<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>
)}
{ch.style?.example_posts?.length > 0 && (
{ch.language && (
<span className="flex items-center gap-1">
<Target className="w-3 h-3 text-accent" />
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'}
<Users className="w-3 h-3" />
{ch.language.toUpperCase()}
</span>
)}
</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 { useRouter } from 'next/navigation';
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 = [
{ v: 'friendly', label: 'Дружелюбный' },
@@ -32,14 +41,14 @@ const EMOJI = [
];
const IMAGE_STYLES = [
{ v: 'realistic-photo', label: 'Реалистичное фото', desc: 'Стоковая фотография' },
{ v: 'flat-illustration', label: 'Плоская иллюстрация', desc: 'Editorial vector' },
{ v: '3d-render', label: '3D рендер', desc: 'Pixar-like' },
{ v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' },
{ v: 'minimal', label: 'Минимализм', desc: 'Один элемент' },
{ v: 'abstract', label: 'Абстракция', desc: 'Без объектов' },
{ v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' },
{ v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' },
{ v: 'realistic-photo', label: 'Реалистичное фото', desc: 'AI-фотореализм, не сток' },
{ v: 'flat-illustration',label: 'Плоская иллюстрация', desc: 'Editorial vector' },
{ v: '3d-render', label: '3D рендер', desc: 'Pixar-like' },
{ v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' },
{ v: 'minimal', label: 'Минимализм', desc: 'Один элемент' },
{ v: 'abstract', label: 'Абстракция', desc: 'Геометрия, настроение' },
{ v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' },
{ v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' },
];
const IMAGE_PALETTES = [
@@ -53,8 +62,10 @@ const IMAGE_PALETTES = [
];
const TABS = [
{ id: 'content', label: 'Контент', icon: Type },
{ id: 'images', label: 'Картинки', icon: ImageIcon },
{ id: 'content', label: 'Контент', icon: Type },
{ id: 'images', label: 'Картинки', icon: ImageIcon },
{ id: 'ai', label: 'AI-стиль', icon: Sparkles },
{ id: 'connect', label: 'Подключение', icon: Plug },
];
export default function ChannelEdit({ channel }) {
@@ -66,6 +77,11 @@ export default function ChannelEdit({ channel }) {
const [name, setName] = useState(channel.name || '');
const [niche, setNiche] = useState(channel.niche || '');
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 [formality, setFormality] = useState(style.formality || 'informal');
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 [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 [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 [deleting, setDeleting] = useState(false);
@@ -91,7 +126,16 @@ export default function ChannelEdit({ channel }) {
setError('');
try {
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: {
tone, formality, humor,
post_length: postLength,
@@ -100,9 +144,10 @@ export default function ChannelEdit({ channel }) {
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
image_enabled: imageEnabled,
image_style: imageStyle,
image_style: imageStyles.join(','),
image_palette: imagePalette,
image_custom_colors: imageCustomColors.trim() || null,
image_prompt_instructions: imagePromptInstructions.trim() || null,
},
};
const res = await fetch(`/api/channels/${channel.id}`, {
@@ -183,6 +228,47 @@ export default function ChannelEdit({ channel }) {
<label className="label">Аудитория</label>
<textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} />
</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 className="card p-5 space-y-4">
@@ -281,22 +367,31 @@ export default function ChannelEdit({ channel }) {
{imageEnabled && (
<>
<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" />
Стиль изображений
<span className="text-gray-500 font-normal">(можно несколько система будет чередовать)</span>
</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">
{IMAGE_STYLES.map(s => (
<button
key={s.v} type="button" onClick={() => setImageStyle(s.v)}
className={`p-3 rounded-lg border text-left transition-colors ${
imageStyle === s.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{s.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
</button>
))}
{IMAGE_STYLES.map(s => {
const on = imageStyles.includes(s.v);
return (
<button
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 ${
on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<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>
@@ -329,6 +424,26 @@ export default function ChannelEdit({ channel }) {
</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 подсказка */}
<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>
@@ -341,6 +456,152 @@ export default function ChannelEdit({ channel }) {
)}
</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>
);
}
+330 -47
View File
@@ -4,8 +4,17 @@ import Link from 'next/link';
import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
Search, Camera, ExternalLink, Link2
} 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 = {
educational: 'Обучение', news: 'Новости',
@@ -22,8 +31,19 @@ const TRANSFORMS = [
{ action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
];
// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать
function buildCaption(domain) {
return domain ? `\n\n📷 Фото: ${domain}` : '';
}
// Удаляет существующую подпись из текста поста (по любому домену)
function stripCaption(text) {
return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd();
}
export default function ChannelView({ channel }) {
const [topic, setTopic] = useState('');
const [customPrompt, setCustomPrompt] = useState('');
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
const [generating, setGenerating] = useState(false);
const [post, setPost] = useState(null);
const [error, setError] = useState('');
@@ -36,8 +56,16 @@ export default function ChannelView({ channel }) {
// Картинка
const [image, setImage] = useState(null);
const [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
const [genImage, setGenImage] = useState(false);
// Photo search modal
const [showPhotoSearch, setShowPhotoSearch] = useState(false);
const [showFromUrl, setShowFromUrl] = useState(false);
const [showPoll, setShowPoll] = useState(false);
const [batchCount, setBatchCount] = useState(3);
const [batchLoading, setBatchLoading] = useState(false);
// Трансформации
const [transforming, setTransforming] = useState(false);
@@ -69,12 +97,12 @@ export default function ChannelView({ channel }) {
// Сохранение и публикация
const [savedPostId, setSavedPostId] = useState(null);
const [publishing, setPublishing] = useState(false);
const [activeTab, setActiveTab] = useState('generate'); // generate | analytics
const [showScheduler, setShowScheduler] = useState(false);
const [scheduleAt, setScheduleAt] = useState('');
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
// Подгрузка истории при монтировании
useEffect(() => { loadHistory(); }, []);
async function loadHistory() {
@@ -86,6 +114,30 @@ export default function ChannelView({ channel }) {
} catch {} finally { setLoadingHistory(false); }
}
function clearImage() {
setImage(null);
setImageCredit(null);
// Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото
if (post) setPost(p => stripCaption(p));
}
function applyPhotoPick({ imageUrl, credit }) {
setImage(imageUrl);
setImageCredit(credit || null);
// Подменяем (или добавляем) caption
if (post && credit?.domain) {
setPost(p => stripCaption(p) + buildCaption(credit.domain));
}
setShowPhotoSearch(false);
}
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) {
if (!post) return;
setPublishing(true);
@@ -93,12 +145,12 @@ export default function ChannelView({ channel }) {
try {
let id = savedPostId;
if (!id) {
// Создаём
const res = await fetch('/api/user-posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: channel.id, content: post, image_url: image,
channel_id: channel.id, content: post,
image_url: image, image_credit: imageCredit,
topic: topic.trim(), status, scheduled_at: scheduledAt,
}),
});
@@ -107,11 +159,15 @@ export default function ChannelView({ channel }) {
id = data.id;
setSavedPostId(id);
} else {
// Обновляем
const res = await fetch(`/api/user-posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }),
body: JSON.stringify({
content: post,
image_url: image,
image_credit: imageCredit,
status, scheduled_at: scheduledAt,
}),
});
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
}
@@ -137,6 +193,7 @@ export default function ChannelView({ channel }) {
setPost(null);
setSavedPostId(null);
setImage(null);
setImageCredit(null);
setTopic('');
} catch (err) { setError(err.message); }
finally { setPublishing(false); }
@@ -151,9 +208,18 @@ export default function ChannelView({ channel }) {
setPost(null);
setSavedPostId(null);
setImage(null);
setImageCredit(null);
setTopic('');
}
function applyTemplate({ topicHint, structure }) {
// Если тема не задана — подставляем подсказку
if (!topic.trim()) setTopic(topicHint);
// В textarea вставляем структуру как отправную точку
setPost(structure);
setSavedPostId(null);
}
async function generate(asVariant = false) {
if (!topic.trim() && !asVariant) return;
if (asVariant && !post) return;
@@ -168,10 +234,18 @@ export default function ChannelView({ channel }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
customPrompt: customPrompt.trim() || undefined,
}),
});
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;
for (let i = 0; i < 60; i++) {
@@ -183,14 +257,14 @@ export default function ChannelView({ channel }) {
if (!final) throw new Error('Таймаут — попробуй ещё раз');
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
// Сохраняем предыдущий вариант в variants
if (asVariant && post) {
setVariants(v => [...v, { content: post, tokens, image }]);
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
}
setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out });
setImage(null); // сбрасываем картинку при новом посте
setImage(null);
setImageCredit(null);
} catch (err) {
setError(err.message);
} finally {
@@ -210,10 +284,10 @@ export default function ChannelView({ channel }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
// Сохраняем текущий в варианты
setVariants(v => [...v, { content: post, tokens, image }]);
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
setPost(data.content);
setImage(null);
setImageCredit(null);
} catch (err) {
setError(err.message);
} finally {
@@ -234,6 +308,7 @@ export default function ChannelView({ channel }) {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
setImage(data.url);
setImageCredit(null); // сгенерированная — без credit'а
} catch (err) {
setError(err.message);
} finally {
@@ -245,12 +320,13 @@ export default function ChannelView({ channel }) {
const v = variants[idx];
setVariants(arr => {
const next = arr.filter((_, i) => i !== idx);
next.push({ content: post, tokens, image });
next.push({ content: post, tokens, image, imageCredit });
return next;
});
setPost(v.content);
setTokens(v.tokens);
setImage(v.image);
setImageCredit(v.imageCredit || null);
}
async function copy() {
@@ -271,7 +347,7 @@ export default function ChannelView({ channel }) {
<Sparkles className="w-5 h-5 text-accent" />
<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">
{GOAL_LABELS[channel.goal] || channel.goal}
{(channel.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')}
</span>
</div>
{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>
</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 */}
<div className="card p-5 mb-6">
<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" />
Сгенерировать пост
</h2>
<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 className="flex items-center gap-3">
<PostTemplates onSelect={applyTemplate} disabled={generating} />
<button
onClick={() => setShowFromUrl(true)}
className="text-xs inline-flex items-center gap-1 text-accent hover:underline"
>
<Link2 className="w-3.5 h-3.5" />
По ссылке
</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>
{/* Список идей */}
{showIdeas && ideas.length > 0 && (
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center justify-between mb-2">
@@ -327,15 +466,49 @@ export default function ChannelView({ channel }) {
)}
<textarea
className="input min-h-[80px] mb-3"
className="input min-h-[80px] mb-2"
value={topic}
onChange={e => setTopic(e.target.value)}
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
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="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>
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? (
@@ -354,7 +527,8 @@ export default function ChannelView({ channel }) {
{/* Result */}
{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">
<h3 className="font-semibold flex items-center gap-2">
Результат
@@ -388,7 +562,6 @@ export default function ChannelView({ channel }) {
</div>
</div>
{/* Сам пост — редактируемый или нет */}
{editing ? (
<textarea
value={post}
@@ -402,32 +575,93 @@ export default function ChannelView({ channel }) {
</div>
)}
{/* Картинка к посту */}
{/* Хештеги */}
<HashtagSuggest
channelId={channel.id}
postText={post}
onAppend={text => setPost(p => (p || '') + text)}
/>
{image && (
<div className="mt-4 relative">
<img src={image} alt="" className="w-full rounded-lg" />
<div className="mt-4">
<div className="relative">
<img src={image} alt="" className="w-full rounded-lg" referrerPolicy="no-referrer" />
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
title="Убрать картинку"
>
<X className="w-4 h-4" />
</button>
</div>
{imageCredit?.domain && (
<div className="mt-2 flex items-center justify-between flex-wrap gap-2 text-xs">
<div className="text-gray-500">
📷 Фото:{' '}
{imageCredit.sourceUrl ? (
<a
href={imageCredit.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
{imageCredit.domain}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-accent">{imageCredit.domain}</span>
)}
</div>
<button
onClick={() => setPost(p => stripCaption(p))}
className="text-gray-500 hover:text-gray-300"
title="Убрать подпись «📷 Фото: …» из текста поста (саму картинку оставить)"
>
убрать подпись из поста
</button>
</div>
)}
</div>
)}
{/* Кнопки получения картинки */}
{!image && (
<div className="mt-4 grid sm:grid-cols-2 gap-2">
<button
onClick={() => setImage(null)}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
onClick={() => setShowPhotoSearch(true)}
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors"
>
<X className="w-4 h-4" />
<Search className="w-4 h-4" /> Найти фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
>
{genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую (~30 сек)</>
) : (
<><Camera className="w-4 h-4" /> Сгенерировать (AI)</>
)}
</button>
</div>
)}
{/* Кнопка генерации картинки */}
{!image && (
<div className="mt-4">
{/* Если уже есть картинка — даём ещё раз поменять */}
{image && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<button
onClick={() => setShowPhotoSearch(true)}
className="btn-ghost text-xs py-1"
>
<Search className="w-3.5 h-3.5" /> Другое фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="w-full inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
className="btn-ghost text-xs py-1"
>
{genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
) : (
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
)}
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
Заменить AI-картинкой
</button>
</div>
)}
@@ -461,7 +695,6 @@ export default function ChannelView({ channel }) {
</button>
</div>
{/* Планировщик */}
{showScheduler && (
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
<label className="label text-xs">Время публикации (МСК)</label>
@@ -500,8 +733,47 @@ export default function ChannelView({ channel }) {
</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 && (
<div className="card p-5">
@@ -545,11 +817,11 @@ export default function ChannelView({ channel }) {
return (
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
{p.image_url && (
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" referrerPolicy="no-referrer" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs flex-wrap">
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
{statusLabels[p.status] || p.status}
</span>
@@ -562,13 +834,23 @@ export default function ChannelView({ channel }) {
{!p.scheduled_at && !p.published_at && (
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.image_credit?.domain && (
<span className="text-gray-500">📷 {p.image_credit.domain}</span>
)}
{p.error && (
<span className="text-red-400 truncate">{p.error}</span>
)}
</div>
</div>
<button
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
onClick={() => {
setPost(p.content);
setImage(p.image_url);
setImageCredit(p.image_credit || null);
setSavedPostId(p.id);
setTopic(p.topic || '');
window.scrollTo({top: 0, behavior: 'smooth'});
}}
className="text-xs text-accent hover:underline shrink-0"
>
открыть
@@ -579,6 +861,7 @@ export default function ChannelView({ channel }) {
</div>
</div>
)}
</> }
</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';
import Link from 'next/link';
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';
export default function Header({ user }) {
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() {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
@@ -17,7 +29,30 @@ export default function Header({ user }) {
<Sparkles className="w-5 h-5 text-accent" />
<span className="font-bold">ZeroPost</span>
</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">
{/* Баланс кредитов */}
{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>
<ThemeToggle />
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
@@ -28,3 +63,21 @@ export default function Header({ user }) {
</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: `[ЗАГОЛОВОК — суть в одной строке]
[23 предложения: что произошло, ключевые цифры]
[Почему это важно для читателя]
[Личный вывод или вопрос к аудитории]`,
},
{
id: 'announce',
label: 'Анонс',
Icon: Megaphone,
hint: 'Интрига → суть → CTA',
topicHint: 'Анонс события, релиза, запуска',
structure: `[Интригующий первый абзац — зачем читать дальше]
📅 [Дата и что именно происходит]
[34 буллита: что будет / что получит читатель]
👉 [Призыв к действию со ссылкой]`,
},
{
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
*/
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';
async function call(path, options = {}) {
@@ -21,7 +21,10 @@ async function call(path, options = {}) {
});
if (!res.ok) {
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();
}
@@ -52,4 +55,65 @@ export const engine = {
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' }),
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() {
const s = await getSession();
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;
}